From bf9f644f76d27f7bb1c71643491a7efaf266822e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 20 Mar 2026 09:59:22 -0700 Subject: [PATCH 01/31] Media: Allow HEIC/HEIF uploads when server lacks support Bypass the `wp_prevent_unsupported_mime_type_uploads` check for HEIC/HEIF images so they can be stored even when the server's image editor doesn't support them. The client-side canvas fallback handles processing using the browser's native HEVC decoder via createImageBitmap(). --- .../class-wp-rest-attachments-controller.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index cb714d5a5de71..50467f788ada4 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -258,6 +258,17 @@ public function create_item_permissions_check( $request ) { $prevent_unsupported_uploads = false; } + // Always allow HEIC/HEIF uploads through even if the server's image + // editor doesn't support them. The client-side canvas fallback will + // handle processing using the browser's native HEVC decoder. + if ( + $prevent_unsupported_uploads && + ! empty( $files['file']['type'] ) && + wp_is_heic_image_mime_type( $files['file']['type'] ) + ) { + $prevent_unsupported_uploads = false; + } + // If the upload is an image, check if the server can handle the mime type. if ( $prevent_unsupported_uploads && From b261f30d1d30e43eef56ddd3b9896e6e632bc595 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:54:02 -0400 Subject: [PATCH 02/31] REST API: Add HEIC client-side support to the sideload route. Extends the /wp/v2/media//sideload route so the client-side media flow can upload a HEIC/HEIF companion original alongside the JPEG derivative: - Adds 'original-heic' to the allowed image_size enum. The companion filename is recorded under $metadata['original'] so it never collides with 'original_image', which the scaled-sideload flow owns. - Adds a 'generate_sub_sizes' boolean arg (default false) so callers that handle processing client-side can suppress server-side sub-size generation per request. - Adds 'image/heif' to the image_output_formats input list returned by the REST API root index. Backport of GB #76731. --- .../rest-api/class-wp-rest-server.php | 2 +- .../class-wp-rest-attachments-controller.php | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 704a990298826..192f76e2d4c5a 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1380,7 +1380,7 @@ public function get_index( $request ) { $available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // Image output formats. - $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' ); + $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif' ); $output_formats = array(); foreach ( $input_formats as $mime_type ) { /** This filter is documented in wp-includes/media.php */ diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 3de551148d99d..d979872f96158 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -68,6 +68,10 @@ public function register_routes() { $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() ); // Special case to set 'original_image' in attachment metadata. $valid_image_sizes[] = 'original'; + // HEIC/HEIF companion original preserved alongside the JPEG derivative. + // Stored under its own meta key so it never collides with 'original' + // (which the scaled-sideload flow also writes to). + $valid_image_sizes[] = 'original-heic'; // Used for PDF thumbnails. $valid_image_sizes[] = 'full'; // Client-side big image threshold: sideload the scaled version. @@ -82,21 +86,26 @@ public function register_routes() { 'callback' => array( $this, 'sideload_item' ), 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), 'args' => array( - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), 'type' => 'integer', ), - 'image_size' => array( + 'image_size' => array( 'description' => __( 'Image size.' ), 'type' => 'string', 'enum' => $valid_image_sizes, 'required' => true, ), - 'convert_format' => array( + 'convert_format' => array( 'type' => 'boolean', 'default' => true, 'description' => __( 'Whether to convert image formats.' ), ), + 'generate_sub_sizes' => array( + 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.' ), + 'type' => 'boolean', + 'default' => false, + ), ), ), 'allow_batch' => $this->allow_batch, @@ -2101,6 +2110,13 @@ public function sideload_item( WP_REST_Request $request ) { if ( 'original' === $image_size ) { $metadata['original_image'] = wp_basename( $path ); + } elseif ( 'original-heic' === $image_size ) { + // HEIC companion original: stored under its own meta key so + // the scaled-sideload flow (which writes 'original_image') + // cannot clobber it. 'original_image' keeps pointing at the + // web-viewable JPEG derivative. Cleanup on attachment delete + // is handled by wp_delete_attachment_heic_companion_file(). + $metadata['original'] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // The current attached file is the original; record it as original_image. $current_file = get_attached_file( $attachment_id, true ); From c711280a0c2b811099c6ea57dcd848e4750eafa2 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:54:09 -0400 Subject: [PATCH 03/31] Media: Delete HEIC companion file when its attachment is deleted. When the client-side media flow sideloads a HEIC original alongside a JPEG derivative, the HEIC filename is stored in $metadata['original']. wp_delete_attachment_files() only tracks 'original_image', so without this hook the HEIC file would linger on disk after the attachment is removed. wp_delete_attachment_heic_companion_file() reads the meta key, guards against non-string values (e.g. arrays written by other flows), and deletes the file when present. Hooked into the delete_attachment action via default-filters.php. Backport of GB #76731, with the is_string() guard from GB #78128. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/media.php | 39 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index cf895eb748dbe..de8e9fb3364d6 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -562,6 +562,7 @@ add_action( 'transition_post_status', '_wp_customize_publish_changeset', 10, 3 ); add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' ); add_action( 'delete_attachment', '_delete_attachment_theme_mod' ); +add_action( 'delete_attachment', 'wp_delete_attachment_heic_companion_file' ); add_action( 'transition_post_status', '_wp_keep_alive_customize_changeset_dependent_auto_drafts', 20, 3 ); // Block Theme Previews. diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index d318a275a9607..13fb4f9c6a7d9 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5760,6 +5760,45 @@ function wp_show_heic_upload_error( $plupload_settings ) { return $plupload_settings; } +/** + * Deletes the HEIC companion file when its attachment is deleted. + * + * When the client-side media flow sideloads a HEIC original alongside a + * JPEG derivative, the HEIC filename is recorded in $metadata['original']. + * WordPress only tracks 'original_image' in wp_delete_attachment_files(), + * so without this hook the HEIC file would linger on disk after the + * attachment is deleted. + * + * @since 7.1.0 + * + * @param int $post_id Attachment ID being deleted. + */ +function wp_delete_attachment_heic_companion_file( $post_id ) { + $metadata = wp_get_attachment_metadata( $post_id, true ); + + if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) { + return; + } + + $attached_file = get_attached_file( $post_id, true ); + + if ( ! $attached_file ) { + return; + } + + $uploads = wp_get_upload_dir(); + + if ( empty( $uploads['basedir'] ) ) { + return; + } + + $heic_path = path_join( dirname( $attached_file ), wp_basename( (string) $metadata['original'] ) ); + + if ( file_exists( $heic_path ) ) { + wp_delete_file_from_directory( $heic_path, $uploads['basedir'] ); + } +} + /** * Allows PHP's getimagesize() to be debuggable when necessary. * From b20bbcaf447c2478680897595abdd8bf23dfa0aa Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 11:57:14 -0400 Subject: [PATCH 04/31] Tests: Cover the HEIC client-side sideload and companion-delete flow. Adds REST API controller tests: - The sideload route exposes 'original-heic' in the image_size enum. - The sideload route exposes a 'generate_sub_sizes' boolean arg defaulting to false. - Sideloading an 'original-heic' image writes the filename to $metadata['original'] and leaves 'original_image' untouched. Adds wp_delete_attachment_heic_companion_file() unit tests: - The companion HEIC is removed when the attachment is deleted. - The hook is a no-op when $metadata['original'] is missing. - The hook bails when $metadata['original'] is not a string (regression coverage for the guard added in GB #78128). --- .../wpDeleteAttachmentHeicCompanionFile.php | 77 +++++++++++++++++++ .../rest-api/rest-attachments-controller.php | 74 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php new file mode 100644 index 0000000000000..d3e2dc0256321 --- /dev/null +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -0,0 +1,77 @@ +remove_added_uploads(); + + parent::tear_down(); + } + + /** + * @ticket 64915 + */ + public function test_deletes_heic_file_recorded_in_metadata_original() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + $attached_file = get_attached_file( $attachment_id, true ); + $dir = dirname( $attached_file ); + $heic_name = 'companion-' . wp_generate_password( 6, false ) . '.heic'; + $heic_path = $dir . '/' . $heic_name; + + // Create a dummy companion file on disk. + file_put_contents( $heic_path, 'test' ); + $this->assertFileExists( $heic_path, 'Test fixture should be on disk.' ); + + // Record the companion under metadata['original'] as the sideload route does. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata['original'] = $heic_name; + wp_update_attachment_metadata( $attachment_id, $metadata ); + + wp_delete_attachment( $attachment_id, true ); + + $this->assertFileDoesNotExist( $heic_path, 'Companion HEIC file should be deleted alongside the attachment.' ); + } + + /** + * @ticket 64915 + */ + public function test_noop_when_metadata_original_is_missing() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + // Sanity: no 'original' key on freshly-created metadata. + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertArrayNotHasKey( 'original', $metadata ); + + // Should not raise even though the hook fires. + wp_delete_attachment( $attachment_id, true ); + + $this->assertNull( get_post( $attachment_id ) ); + } + + /** + * Guards against $metadata['original'] holding a non-string value (e.g. + * the array form some flows write). Regression coverage for GB #78128. + * + * @ticket 64915 + */ + public function test_noop_when_metadata_original_is_not_a_string() { + $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $attached_file = get_attached_file( $attachment_id, true ); + + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata['original'] = array( 'file' => 'should-not-delete.heic' ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + + // Should not raise (no path_join() / file_exists() on an array). + wp_delete_attachment_heic_companion_file( $attachment_id ); + + $this->assertFileExists( $attached_file, 'Attached file should still be on disk; the hook must bail on non-string original.' ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..def3559b3d602 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3351,6 +3351,80 @@ public function test_sideload_route_includes_scaled_enum() { $this->assertContains( 'scaled', $args[ $param_name ]['enum'], 'image_size enum should include scaled.' ); } + /** + * Tests that the sideload endpoint includes 'original-heic' in the image_size enum. + * + * @ticket 64915 + */ + public function test_sideload_route_includes_original_heic_enum() { + $this->enable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $endpoint = $routes['/wp/v2/media/(?P[\d]+)/sideload'][0]; + $args = $endpoint['args']; + + $this->assertArrayHasKey( 'image_size', $args, 'Route should have image_size arg.' ); + $this->assertContains( 'original-heic', $args['image_size']['enum'], 'image_size enum should include original-heic.' ); + } + + /** + * Tests that the sideload endpoint exposes the generate_sub_sizes arg. + * + * @ticket 64915 + */ + public function test_sideload_route_includes_generate_sub_sizes_arg() { + $this->enable_client_side_media_processing(); + + $routes = rest_get_server()->get_routes(); + $endpoint = $routes['/wp/v2/media/(?P[\d]+)/sideload'][0]; + $args = $endpoint['args']; + + $this->assertArrayHasKey( 'generate_sub_sizes', $args, 'Route should have generate_sub_sizes arg.' ); + $this->assertSame( 'boolean', $args['generate_sub_sizes']['type'], 'generate_sub_sizes should be a boolean.' ); + $this->assertFalse( $args['generate_sub_sizes']['default'], 'generate_sub_sizes should default to false on sideload.' ); + } + + /** + * Tests sideloading an 'original-heic' companion file alongside its JPEG + * derivative. The HEIC filename is recorded under $metadata['original'] + * so it does not collide with 'original_image', which the scaled-sideload + * flow owns. + * + * @ticket 64915 + * @requires function imagejpeg + */ + public function test_sideload_original_heic_writes_metadata_original() { + $this->enable_client_side_media_processing(); + + wp_set_current_user( self::$author_id ); + + // Create the JPEG attachment that the HEIC will be a companion to. + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + $attachment_id = $response->get_data()['id']; + + $this->assertSame( 201, $response->get_status() ); + + // Sideload the HEIC companion. Uses a JPEG body since the size enum, + // not the file format, is what we're exercising here. + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); + $request->set_param( 'image_size', 'original-heic' ); + $request->set_body( file_get_contents( self::$test_file ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertArrayHasKey( 'original', $metadata, "Metadata should contain 'original' for the HEIC companion." ); + $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['original'], "Metadata 'original' should reference the HEIC filename." ); + $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." ); + } + /** * Tests the filter_wp_unique_filename method handles the -scaled suffix. * From eea07d222055212a5660255fb078aa6860778bee Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 12:39:17 -0400 Subject: [PATCH 05/31] Tests: Use HEIC fixture and convert_format=false for original-heic sideload. The test was sending JPEG bytes with a .heic filename, which wp_check_filetype_and_ext() corrected to canola-1.jpg before the metadata assertion ran. Switch to the real test-image.heic fixture, set Content-Type accordingly, and pass convert_format=false to disable the default HEIC -> JPEG output mapping so the .heic extension is preserved. --- .../tests/rest-api/rest-attachments-controller.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index def3559b3d602..acadc3556247a 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3408,13 +3408,15 @@ public function test_sideload_original_heic_writes_metadata_original() { $this->assertSame( 201, $response->get_status() ); - // Sideload the HEIC companion. Uses a JPEG body since the size enum, - // not the file format, is what we're exercising here. + // Sideload the HEIC companion using the real HEIC fixture. `convert_format` + // is disabled so the default HEIC -> JPEG output mapping does not rename + // the file or append an alt-extension suffix. $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment_id}/sideload" ); - $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Type', 'image/heic' ); $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); $request->set_param( 'image_size', 'original-heic' ); - $request->set_body( file_get_contents( self::$test_file ) ); + $request->set_param( 'convert_format', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.heic' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' ); From d976d2e4a675d868621144acb429c0d06cdc1d2b Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 13:28:42 -0400 Subject: [PATCH 06/31] Tests: Refresh wp-api-generated.js fixture for the sideload route. Add 'original-heic' to the image_size enum and the missing generate_sub_sizes arg so the schema fixture matches what the live REST index now reports. Without this the test-fixtures step fails the git diff --exit-code check. --- tests/qunit/fixtures/wp-api-generated.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..c3bf2bf452928 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3703,6 +3703,7 @@ mockedApiResponse.Schema = { "1536x1536", "2048x2048", "original", + "original-heic", "full", "scaled" ], @@ -3713,6 +3714,12 @@ mockedApiResponse.Schema = { "default": true, "description": "Whether to convert image formats.", "required": false + }, + "generate_sub_sizes": { + "description": "Whether to generate image sub sizes from the sideloaded file.", + "type": "boolean", + "default": false, + "required": false } } } From d01bab6df3ec671f3d78bec2b97eeb2a19b9c138 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 28 May 2026 10:50:36 -0700 Subject: [PATCH 07/31] Add void return type hints --- src/wp-includes/media.php | 2 +- .../tests/media/wpDeleteAttachmentHeicCompanionFile.php | 8 ++++---- .../tests/rest-api/rest-attachments-controller.php | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 13fb4f9c6a7d9..9c1c2292e4a13 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5773,7 +5773,7 @@ function wp_show_heic_upload_error( $plupload_settings ) { * * @param int $post_id Attachment ID being deleted. */ -function wp_delete_attachment_heic_companion_file( $post_id ) { +function wp_delete_attachment_heic_companion_file( $post_id ): void { $metadata = wp_get_attachment_metadata( $post_id, true ); if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) { diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php index d3e2dc0256321..ed554671f6073 100644 --- a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -8,7 +8,7 @@ */ class Tests_Media_wpDeleteAttachmentHeicCompanionFile extends WP_UnitTestCase { - public function tear_down() { + public function tear_down(): void { $this->remove_added_uploads(); parent::tear_down(); @@ -17,7 +17,7 @@ public function tear_down() { /** * @ticket 64915 */ - public function test_deletes_heic_file_recorded_in_metadata_original() { + public function test_deletes_heic_file_recorded_in_metadata_original(): void { $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $attached_file = get_attached_file( $attachment_id, true ); @@ -42,7 +42,7 @@ public function test_deletes_heic_file_recorded_in_metadata_original() { /** * @ticket 64915 */ - public function test_noop_when_metadata_original_is_missing() { + public function test_noop_when_metadata_original_is_missing(): void { $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); // Sanity: no 'original' key on freshly-created metadata. @@ -61,7 +61,7 @@ public function test_noop_when_metadata_original_is_missing() { * * @ticket 64915 */ - public function test_noop_when_metadata_original_is_not_a_string() { + public function test_noop_when_metadata_original_is_not_a_string(): void { $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $attached_file = get_attached_file( $attachment_id, true ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index acadc3556247a..9bb0371cf2522 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3356,7 +3356,7 @@ public function test_sideload_route_includes_scaled_enum() { * * @ticket 64915 */ - public function test_sideload_route_includes_original_heic_enum() { + public function test_sideload_route_includes_original_heic_enum(): void { $this->enable_client_side_media_processing(); $routes = rest_get_server()->get_routes(); @@ -3372,7 +3372,7 @@ public function test_sideload_route_includes_original_heic_enum() { * * @ticket 64915 */ - public function test_sideload_route_includes_generate_sub_sizes_arg() { + public function test_sideload_route_includes_generate_sub_sizes_arg(): void { $this->enable_client_side_media_processing(); $routes = rest_get_server()->get_routes(); @@ -3393,7 +3393,7 @@ public function test_sideload_route_includes_generate_sub_sizes_arg() { * @ticket 64915 * @requires function imagejpeg */ - public function test_sideload_original_heic_writes_metadata_original() { + public function test_sideload_original_heic_writes_metadata_original(): void { $this->enable_client_side_media_processing(); wp_set_current_user( self::$author_id ); From f728cfb6921645d03afef7e3b4544d3a63fd57d7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 28 May 2026 10:52:04 -0700 Subject: [PATCH 08/31] Use non-deprecated factory --- .../tests/media/wpDeleteAttachmentHeicCompanionFile.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php index ed554671f6073..f2e4382056130 100644 --- a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -18,7 +18,7 @@ public function tear_down(): void { * @ticket 64915 */ public function test_deletes_heic_file_recorded_in_metadata_original(): void { - $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $attached_file = get_attached_file( $attachment_id, true ); $dir = dirname( $attached_file ); @@ -43,7 +43,7 @@ public function test_deletes_heic_file_recorded_in_metadata_original(): void { * @ticket 64915 */ public function test_noop_when_metadata_original_is_missing(): void { - $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); // Sanity: no 'original' key on freshly-created metadata. $metadata = wp_get_attachment_metadata( $attachment_id, true ); @@ -62,7 +62,7 @@ public function test_noop_when_metadata_original_is_missing(): void { * @ticket 64915 */ public function test_noop_when_metadata_original_is_not_a_string(): void { - $attachment_id = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $attached_file = get_attached_file( $attachment_id, true ); $metadata = wp_get_attachment_metadata( $attachment_id, true ); From d6f69d6c1818c8bb43a467248a7a2869b6753b5f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 28 May 2026 10:52:35 -0700 Subject: [PATCH 09/31] Add assertions for successful attachment creation --- .../tests/media/wpDeleteAttachmentHeicCompanionFile.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php index f2e4382056130..ba6b997447e9a 100644 --- a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -19,6 +19,7 @@ public function tear_down(): void { */ public function test_deletes_heic_file_recorded_in_metadata_original(): void { $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $attachment_id ); $attached_file = get_attached_file( $attachment_id, true ); $dir = dirname( $attached_file ); @@ -44,6 +45,7 @@ public function test_deletes_heic_file_recorded_in_metadata_original(): void { */ public function test_noop_when_metadata_original_is_missing(): void { $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $attachment_id ); // Sanity: no 'original' key on freshly-created metadata. $metadata = wp_get_attachment_metadata( $attachment_id, true ); @@ -63,6 +65,7 @@ public function test_noop_when_metadata_original_is_missing(): void { */ public function test_noop_when_metadata_original_is_not_a_string(): void { $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + $this->assertIsInt( $attachment_id ); $attached_file = get_attached_file( $attachment_id, true ); $metadata = wp_get_attachment_metadata( $attachment_id, true ); From beb9ffe83d244824111ac0b5a589cbbf4cf14f87 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 28 May 2026 11:10:13 -0700 Subject: [PATCH 10/31] Add types and type assertions to tests --- .../wpDeleteAttachmentHeicCompanionFile.php | 15 ++++--- .../rest-api/rest-attachments-controller.php | 39 ++++++++++--------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php index ba6b997447e9a..d8d0906fc9717 100644 --- a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -22,16 +22,18 @@ public function test_deletes_heic_file_recorded_in_metadata_original(): void { $this->assertIsInt( $attachment_id ); $attached_file = get_attached_file( $attachment_id, true ); - $dir = dirname( $attached_file ); - $heic_name = 'companion-' . wp_generate_password( 6, false ) . '.heic'; - $heic_path = $dir . '/' . $heic_name; + $this->assertIsString( $attached_file ); + $dir = dirname( $attached_file ); + $heic_name = 'companion-' . wp_generate_password( 6, false ) . '.heic'; + $heic_path = $dir . '/' . $heic_name; // Create a dummy companion file on disk. file_put_contents( $heic_path, 'test' ); $this->assertFileExists( $heic_path, 'Test fixture should be on disk.' ); // Record the companion under metadata['original'] as the sideload route does. - $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); $metadata['original'] = $heic_name; wp_update_attachment_metadata( $attachment_id, $metadata ); @@ -49,6 +51,7 @@ public function test_noop_when_metadata_original_is_missing(): void { // Sanity: no 'original' key on freshly-created metadata. $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); $this->assertArrayNotHasKey( 'original', $metadata ); // Should not raise even though the hook fires. @@ -67,8 +70,10 @@ public function test_noop_when_metadata_original_is_not_a_string(): void { $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $this->assertIsInt( $attachment_id ); $attached_file = get_attached_file( $attachment_id, true ); + $this->assertIsString( $attached_file ); - $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $metadata = wp_get_attachment_metadata( $attachment_id, true ); + $this->assertIsArray( $metadata ); $metadata['original'] = array( 'file' => 'should-not-delete.heic' ); wp_update_attachment_metadata( $attachment_id, $metadata ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 9bb0371cf2522..f6614566684c6 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -9,53 +9,53 @@ */ class WP_Test_REST_Attachments_Controller extends WP_Test_REST_Post_Type_Controller_Testcase { - protected static $superadmin_id; - protected static $editor_id; - protected static $author_id; - protected static $contributor_id; - protected static $uploader_id; - protected static $rest_after_insert_attachment_count; - protected static $rest_insert_attachment_count; + protected static int $superadmin_id; + protected static int $editor_id; + protected static int $author_id; + protected static int $contributor_id; + protected static int $uploader_id; + protected static int $rest_after_insert_attachment_count; + protected static int $rest_insert_attachment_count; /** * @var string The path to a test file. */ - private static $test_file; + private static string $test_file; /** * @var string The path to a second test file. */ - private static $test_file2; + private static string $test_file2; /** * @var string The path to the AVIF test image. */ - private static $test_avif_file; + private static string $test_avif_file; /** * @var string The path to the SVG test image. */ - private static $test_svg_file; + private static string $test_svg_file; /** * @var string The path to the test video. */ - private static $test_video_file; + private static string $test_video_file; /** * @var string The path to the test audio. */ - private static $test_audio_file; + private static string $test_audio_file; /** * @var string The path to the test RTF file. */ - private static $test_rtf_file; + private static string $test_rtf_file; /** - * @var array The recorded posts query clauses. + * @var string[] The recorded posts query clauses. */ - protected $posts_clauses; + protected array $posts_clauses; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$superadmin_id = $factory->user->create( @@ -3378,6 +3378,7 @@ public function test_sideload_route_includes_generate_sub_sizes_arg(): void { $routes = rest_get_server()->get_routes(); $endpoint = $routes['/wp/v2/media/(?P[\d]+)/sideload'][0]; $args = $endpoint['args']; + $this->assertIsArray( $args ); $this->assertArrayHasKey( 'generate_sub_sizes', $args, 'Route should have generate_sub_sizes arg.' ); $this->assertSame( 'boolean', $args['generate_sub_sizes']['type'], 'generate_sub_sizes should be a boolean.' ); @@ -3402,9 +3403,10 @@ public function test_sideload_original_heic_writes_metadata_original(): void { $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); $request->set_header( 'Content-Type', 'image/jpeg' ); $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); - $request->set_body( file_get_contents( self::$test_file ) ); + $request->set_body( (string) file_get_contents( self::$test_file ) ); $response = rest_get_server()->dispatch( $request ); $attachment_id = $response->get_data()['id']; + $this->assertIsInt( $attachment_id ); $this->assertSame( 201, $response->get_status() ); @@ -3416,12 +3418,13 @@ public function test_sideload_original_heic_writes_metadata_original(): void { $request->set_header( 'Content-Disposition', 'attachment; filename=canola.heic' ); $request->set_param( 'image_size', 'original-heic' ); $request->set_param( 'convert_format', false ); - $request->set_body( file_get_contents( DIR_TESTDATA . '/images/test-image.heic' ) ); + $request->set_body( (string) file_get_contents( DIR_TESTDATA . '/images/test-image.heic' ) ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status(), 'Sideloading original-heic should succeed.' ); $metadata = wp_get_attachment_metadata( $attachment_id ); + $this->assertIsArray( $metadata ); $this->assertArrayHasKey( 'original', $metadata, "Metadata should contain 'original' for the HEIC companion." ); $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['original'], "Metadata 'original' should reference the HEIC filename." ); $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." ); From 9b6b7686e009876628ff48301a5525674a8a3b31 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Thu, 28 May 2026 15:01:22 -0400 Subject: [PATCH 11/31] Update src/wp-includes/media.php Co-authored-by: Weston Ruter --- src/wp-includes/media.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 9c1c2292e4a13..4f9a3001b9630 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5792,7 +5792,7 @@ function wp_delete_attachment_heic_companion_file( $post_id ): void { return; } - $heic_path = path_join( dirname( $attached_file ), wp_basename( (string) $metadata['original'] ) ); + $heic_path = path_join( dirname( $attached_file ), wp_basename( $metadata['original'] ) ); if ( file_exists( $heic_path ) ) { wp_delete_file_from_directory( $heic_path, $uploads['basedir'] ); From 7f976bb314ddcd366f2e3eb0a333a12044ecd8c4 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 16:47:12 -0400 Subject: [PATCH 12/31] REST API: Address review feedback on HEIC client-side sideload support. Harden the companion-original handling surfaced in review: - Rename the companion metadata key from the over-generic 'original' to 'source_image' so unrelated plugin or theme data stored under 'original' can no longer drive file deletion on attachment delete. - Add IMAGE_SIZE_SOURCE_ORIGINAL and META_KEY_SOURCE_IMAGE class constants so the sideload image_size enum and its dispatch branch cannot drift. - Drop the unused generate_sub_sizes argument from the /sideload route schema; only create_item() reads it, so advertising it on sideload silently misleads clients. - Advertise the HEIC/HEIF -sequence variants in the REST index input formats so they match wp_is_heic_image_mime_type(). - Return a boolean from wp_delete_attachment_heic_companion_file() and strengthen the non-string guard test with a real on-disk bystander file so the regression it protects against can actually fail. --- src/wp-includes/media.php | 33 +++++++----- .../rest-api/class-wp-rest-server.php | 2 +- .../class-wp-rest-attachments-controller.php | 54 +++++++++++++------ .../wpDeleteAttachmentHeicCompanionFile.php | 43 +++++++++------ .../rest-api/rest-attachments-controller.php | 20 +++---- 5 files changed, 95 insertions(+), 57 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 4f9a3001b9630..48c4124a27313 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5761,42 +5761,47 @@ function wp_show_heic_upload_error( $plupload_settings ) { } /** - * Deletes the HEIC companion file when its attachment is deleted. + * Deletes the source-format companion file when its attachment is deleted. * - * When the client-side media flow sideloads a HEIC original alongside a - * JPEG derivative, the HEIC filename is recorded in $metadata['original']. - * WordPress only tracks 'original_image' in wp_delete_attachment_files(), - * so without this hook the HEIC file would linger on disk after the - * attachment is deleted. + * When the client-side media flow sideloads a source-format original (such as + * a HEIC file) alongside a web-viewable derivative, the original's filename is + * recorded in the 'source_image' metadata key. WordPress only tracks + * 'original_image' in wp_delete_attachment_files(), so without this hook the + * companion file would linger on disk after the attachment is deleted. * * @since 7.1.0 * * @param int $post_id Attachment ID being deleted. + * @return bool Whether a companion file was deleted. */ -function wp_delete_attachment_heic_companion_file( $post_id ): void { +function wp_delete_attachment_heic_companion_file( $post_id ): bool { $metadata = wp_get_attachment_metadata( $post_id, true ); - if ( empty( $metadata['original'] ) || ! is_string( $metadata['original'] ) ) { - return; + if ( empty( $metadata['source_image'] ) || ! is_string( $metadata['source_image'] ) ) { + return false; } $attached_file = get_attached_file( $post_id, true ); if ( ! $attached_file ) { - return; + return false; } $uploads = wp_get_upload_dir(); if ( empty( $uploads['basedir'] ) ) { - return; + return false; } - $heic_path = path_join( dirname( $attached_file ), wp_basename( $metadata['original'] ) ); + $companion_path = path_join( dirname( $attached_file ), wp_basename( $metadata['source_image'] ) ); - if ( file_exists( $heic_path ) ) { - wp_delete_file_from_directory( $heic_path, $uploads['basedir'] ); + if ( ! file_exists( $companion_path ) ) { + return false; } + + wp_delete_file_from_directory( $companion_path, $uploads['basedir'] ); + + return true; } /** diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 192f76e2d4c5a..adbd4ff5f5af1 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1380,7 +1380,7 @@ public function get_index( $request ) { $available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); // Image output formats. - $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif' ); + $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence' ); $output_formats = array(); foreach ( $input_formats as $mime_type ) { /** This filter is documented in wp-includes/media.php */ diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index d979872f96158..9b4d0c49f7191 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -24,6 +24,29 @@ class WP_REST_Attachments_Controller extends WP_REST_Posts_Controller { */ protected $allow_batch = false; + /** + * Image size token for the source-format original preserved alongside a + * client-generated derivative (e.g. the HEIC file kept next to its JPEG). + * + * Used both in the `/sideload` route schema and when dispatching the + * sideloaded file to its metadata key, so the two never drift apart. + * + * @since 7.1.0 + * @var string + */ + const IMAGE_SIZE_SOURCE_ORIGINAL = 'original-heic'; + + /** + * Metadata key holding the basename of the source-format original. + * + * Deliberately specific so it never collides with the generic `original` + * or `original_image` keys other flows write to. + * + * @since 7.1.0 + * @var string + */ + const META_KEY_SOURCE_IMAGE = 'source_image'; + /** * Registers the routes for attachments. * @@ -68,10 +91,12 @@ public function register_routes() { $valid_image_sizes = array_keys( wp_get_registered_image_subsizes() ); // Special case to set 'original_image' in attachment metadata. $valid_image_sizes[] = 'original'; - // HEIC/HEIF companion original preserved alongside the JPEG derivative. - // Stored under its own meta key so it never collides with 'original' - // (which the scaled-sideload flow also writes to). - $valid_image_sizes[] = 'original-heic'; + // Source-format original preserved alongside a client-generated + // derivative (e.g. the HEIC kept next to its JPEG). Stored under + // the dedicated self::META_KEY_SOURCE_IMAGE key so it never + // collides with 'original_image' (which the scaled-sideload flow + // also writes to). + $valid_image_sizes[] = self::IMAGE_SIZE_SOURCE_ORIGINAL; // Used for PDF thumbnails. $valid_image_sizes[] = 'full'; // Client-side big image threshold: sideload the scaled version. @@ -86,26 +111,21 @@ public function register_routes() { 'callback' => array( $this, 'sideload_item' ), 'permission_callback' => array( $this, 'sideload_item_permissions_check' ), 'args' => array( - 'id' => array( + 'id' => array( 'description' => __( 'Unique identifier for the attachment.' ), 'type' => 'integer', ), - 'image_size' => array( + 'image_size' => array( 'description' => __( 'Image size.' ), 'type' => 'string', 'enum' => $valid_image_sizes, 'required' => true, ), - 'convert_format' => array( + 'convert_format' => array( 'type' => 'boolean', 'default' => true, 'description' => __( 'Whether to convert image formats.' ), ), - 'generate_sub_sizes' => array( - 'description' => __( 'Whether to generate image sub sizes from the sideloaded file.' ), - 'type' => 'boolean', - 'default' => false, - ), ), ), 'allow_batch' => $this->allow_batch, @@ -2110,13 +2130,13 @@ public function sideload_item( WP_REST_Request $request ) { if ( 'original' === $image_size ) { $metadata['original_image'] = wp_basename( $path ); - } elseif ( 'original-heic' === $image_size ) { - // HEIC companion original: stored under its own meta key so - // the scaled-sideload flow (which writes 'original_image') - // cannot clobber it. 'original_image' keeps pointing at the + } elseif ( self::IMAGE_SIZE_SOURCE_ORIGINAL === $image_size ) { + // Source-format original: stored under its own meta key so the + // scaled-sideload flow (which writes 'original_image') cannot + // clobber it. 'original_image' keeps pointing at the // web-viewable JPEG derivative. Cleanup on attachment delete // is handled by wp_delete_attachment_heic_companion_file(). - $metadata['original'] = wp_basename( $path ); + $metadata[ self::META_KEY_SOURCE_IMAGE ] = wp_basename( $path ); } elseif ( 'scaled' === $image_size ) { // The current attached file is the original; record it as original_image. $current_file = get_attached_file( $attachment_id, true ); diff --git a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php index d8d0906fc9717..3fede6474571f 100644 --- a/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php +++ b/tests/phpunit/tests/media/wpDeleteAttachmentHeicCompanionFile.php @@ -17,7 +17,7 @@ public function tear_down(): void { /** * @ticket 64915 */ - public function test_deletes_heic_file_recorded_in_metadata_original(): void { + public function test_deletes_companion_file_recorded_in_metadata_source_image(): void { $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $this->assertIsInt( $attachment_id ); @@ -31,55 +31,66 @@ public function test_deletes_heic_file_recorded_in_metadata_original(): void { file_put_contents( $heic_path, 'test' ); $this->assertFileExists( $heic_path, 'Test fixture should be on disk.' ); - // Record the companion under metadata['original'] as the sideload route does. + // Record the companion under metadata['source_image'] as the sideload route does. $metadata = wp_get_attachment_metadata( $attachment_id, true ); $this->assertIsArray( $metadata ); - $metadata['original'] = $heic_name; + $metadata['source_image'] = $heic_name; wp_update_attachment_metadata( $attachment_id, $metadata ); - wp_delete_attachment( $attachment_id, true ); - - $this->assertFileDoesNotExist( $heic_path, 'Companion HEIC file should be deleted alongside the attachment.' ); + $this->assertTrue( + wp_delete_attachment_heic_companion_file( $attachment_id ), + 'Function should report that a companion file was deleted.' + ); + $this->assertFileDoesNotExist( $heic_path, 'Companion file should be deleted alongside the attachment.' ); } /** * @ticket 64915 */ - public function test_noop_when_metadata_original_is_missing(): void { + public function test_noop_when_metadata_source_image_is_missing(): void { $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $this->assertIsInt( $attachment_id ); - // Sanity: no 'original' key on freshly-created metadata. + // Sanity: no 'source_image' key on freshly-created metadata. $metadata = wp_get_attachment_metadata( $attachment_id, true ); $this->assertIsArray( $metadata ); - $this->assertArrayNotHasKey( 'original', $metadata ); + $this->assertArrayNotHasKey( 'source_image', $metadata ); + + // Should report no deletion and not raise even though the hook fires. + $this->assertFalse( wp_delete_attachment_heic_companion_file( $attachment_id ) ); - // Should not raise even though the hook fires. wp_delete_attachment( $attachment_id, true ); $this->assertNull( get_post( $attachment_id ) ); } /** - * Guards against $metadata['original'] holding a non-string value (e.g. + * Guards against $metadata['source_image'] holding a non-string value (e.g. * the array form some flows write). Regression coverage for GB #78128. * * @ticket 64915 */ - public function test_noop_when_metadata_original_is_not_a_string(): void { + public function test_noop_when_metadata_source_image_is_not_a_string(): void { $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); $this->assertIsInt( $attachment_id ); $attached_file = get_attached_file( $attachment_id, true ); $this->assertIsString( $attached_file ); + // Place a real file that a buggy, guard-less implementation could try to + // delete after running wp_basename() over the array value below. + $bystander_path = dirname( $attached_file ) . '/should-not-delete.heic'; + file_put_contents( $bystander_path, 'test' ); + $this->assertFileExists( $bystander_path, 'Test fixture should be on disk.' ); + $metadata = wp_get_attachment_metadata( $attachment_id, true ); $this->assertIsArray( $metadata ); - $metadata['original'] = array( 'file' => 'should-not-delete.heic' ); + $metadata['source_image'] = array( 'file' => 'should-not-delete.heic' ); wp_update_attachment_metadata( $attachment_id, $metadata ); - // Should not raise (no path_join() / file_exists() on an array). - wp_delete_attachment_heic_companion_file( $attachment_id ); + // Should report no deletion and not raise (no path_join() / file_exists() on an array). + $this->assertFalse( wp_delete_attachment_heic_companion_file( $attachment_id ) ); - $this->assertFileExists( $attached_file, 'Attached file should still be on disk; the hook must bail on non-string original.' ); + $this->assertFileExists( $bystander_path, 'The non-string guard must prevent any file deletion.' ); + $this->assertFileExists( $attached_file, 'Attached file should still be on disk.' ); } } diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index f6614566684c6..75db51c319cea 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3368,11 +3368,15 @@ public function test_sideload_route_includes_original_heic_enum(): void { } /** - * Tests that the sideload endpoint exposes the generate_sub_sizes arg. + * Tests that the sideload endpoint does not expose a generate_sub_sizes arg. + * + * sideload_item() never reads the parameter, so advertising it on the route + * would silently mislead clients into expecting server-side sub-size + * generation. The arg only does real work on create_item() (POST /wp/v2/media). * * @ticket 64915 */ - public function test_sideload_route_includes_generate_sub_sizes_arg(): void { + public function test_sideload_route_excludes_generate_sub_sizes_arg(): void { $this->enable_client_side_media_processing(); $routes = rest_get_server()->get_routes(); @@ -3380,21 +3384,19 @@ public function test_sideload_route_includes_generate_sub_sizes_arg(): void { $args = $endpoint['args']; $this->assertIsArray( $args ); - $this->assertArrayHasKey( 'generate_sub_sizes', $args, 'Route should have generate_sub_sizes arg.' ); - $this->assertSame( 'boolean', $args['generate_sub_sizes']['type'], 'generate_sub_sizes should be a boolean.' ); - $this->assertFalse( $args['generate_sub_sizes']['default'], 'generate_sub_sizes should default to false on sideload.' ); + $this->assertArrayNotHasKey( 'generate_sub_sizes', $args, 'Sideload route should not advertise the unused generate_sub_sizes arg.' ); } /** * Tests sideloading an 'original-heic' companion file alongside its JPEG - * derivative. The HEIC filename is recorded under $metadata['original'] + * derivative. The HEIC filename is recorded under $metadata['source_image'] * so it does not collide with 'original_image', which the scaled-sideload * flow owns. * * @ticket 64915 * @requires function imagejpeg */ - public function test_sideload_original_heic_writes_metadata_original(): void { + public function test_sideload_original_heic_writes_metadata_source_image(): void { $this->enable_client_side_media_processing(); wp_set_current_user( self::$author_id ); @@ -3425,8 +3427,8 @@ public function test_sideload_original_heic_writes_metadata_original(): void { $metadata = wp_get_attachment_metadata( $attachment_id ); $this->assertIsArray( $metadata ); - $this->assertArrayHasKey( 'original', $metadata, "Metadata should contain 'original' for the HEIC companion." ); - $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['original'], "Metadata 'original' should reference the HEIC filename." ); + $this->assertArrayHasKey( 'source_image', $metadata, "Metadata should contain 'source_image' for the HEIC companion." ); + $this->assertMatchesRegularExpression( '/canola.*\.heic$/', $metadata['source_image'], "Metadata 'source_image' should reference the HEIC filename." ); $this->assertArrayNotHasKey( 'original_image', $metadata, "Metadata 'original_image' should be untouched by the HEIC sideload." ); } From df33a076fc54ebf7159e2cb164b7f0ed46d02ec5 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 28 May 2026 16:52:16 -0400 Subject: [PATCH 13/31] Tests: Sync wp-api-generated.js fixture after dropping sideload generate_sub_sizes. --- tests/qunit/fixtures/wp-api-generated.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index c3bf2bf452928..7dc1643cb5c6e 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3714,12 +3714,6 @@ mockedApiResponse.Schema = { "default": true, "description": "Whether to convert image formats.", "required": false - }, - "generate_sub_sizes": { - "description": "Whether to generate image sub sizes from the sideloaded file.", - "type": "boolean", - "default": false, - "required": false } } } From c3c02f9a58e4ebfb826c862b120b28a993f31790 Mon Sep 17 00:00:00 2001 From: Adam Silverstein Date: Thu, 18 Jun 2026 09:45:07 +0200 Subject: [PATCH 14/31] Apply suggestions from code review Co-authored-by: Weston Ruter --- src/wp-includes/media.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 48c4124a27313..4eb9187aec4c6 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5776,8 +5776,9 @@ function wp_show_heic_upload_error( $plupload_settings ) { */ function wp_delete_attachment_heic_companion_file( $post_id ): bool { $metadata = wp_get_attachment_metadata( $post_id, true ); - - if ( empty( $metadata['source_image'] ) || ! is_string( $metadata['source_image'] ) ) { + + $source_image = $metadata['source_image'] ?? null; + if ( ! is_string( $source_image ) || '' === $source_image ) { return false; } @@ -5793,15 +5794,13 @@ function wp_delete_attachment_heic_companion_file( $post_id ): bool { return false; } - $companion_path = path_join( dirname( $attached_file ), wp_basename( $metadata['source_image'] ) ); + $companion_path = path_join( dirname( $attached_file ), wp_basename( $source_image ) ); if ( ! file_exists( $companion_path ) ) { return false; } - wp_delete_file_from_directory( $companion_path, $uploads['basedir'] ); - - return true; + return wp_delete_file_from_directory( $companion_path, $uploads['basedir'] ); } /** From a07580ee7bc72f78cebd2f58c1c01ce6fe76258c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 16:41:12 -0700 Subject: [PATCH 15/31] Fix Squiz.WhiteSpace.SuperfluousWhitespace.EndLine --- src/wp-includes/media.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 955b3e60721e7..11b5f12d26fda 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5787,9 +5787,9 @@ function wp_show_heic_upload_error( $plupload_settings ) { */ function wp_delete_attachment_heic_companion_file( $post_id ): bool { $metadata = wp_get_attachment_metadata( $post_id, true ); - - $source_image = $metadata['source_image'] ?? null; - if ( ! is_string( $source_image ) || '' === $source_image ) { + + $source_image = $metadata['source_image'] ?? null; + if ( ! is_string( $source_image ) || '' === $source_image ) { return false; } From 9f40ed46f1ef5fec8712142857c4e5e67ac76df0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 16:44:00 -0700 Subject: [PATCH 16/31] Incorporate file.php phpstan-return types from https://github.com/WordPress/wordpress-develop/pull/12003 --- src/wp-admin/includes/file.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 0c6d968ea02d3..d7c771444ac98 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -801,6 +801,9 @@ function validate_file_to_edit( $file, $allowed_files = array() ) { * @type string $url URL of the newly-uploaded file. * @type string $type Mime type of the newly-uploaded file. * } + * + * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string } + * |array{ error: non-empty-string } */ function _wp_handle_upload( &$file, $overrides, $time, $action ) { // The default error handler. @@ -1094,6 +1097,9 @@ function wp_handle_upload_error( &$file, $message ) { * See _wp_handle_upload() for accepted values. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array See _wp_handle_upload() for return value. + * + * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string } + * |array{ error: non-empty-string } */ function wp_handle_upload( &$file, $overrides = false, $time = null ) { /* @@ -1121,6 +1127,9 @@ function wp_handle_upload( &$file, $overrides = false, $time = null ) { * See _wp_handle_upload() for accepted values. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array See _wp_handle_upload() for return value. + * + * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string } + * |array{ error: non-empty-string } */ function wp_handle_sideload( &$file, $overrides = false, $time = null ) { /* From b11a7749c9ef9bb01a5af5b7dd7a27f995d0a105 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 16:47:50 -0700 Subject: [PATCH 17/31] Add return types for wp_get_upload_dir() and wp_upload_dir() --- src/wp-includes/functions.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 355d9f8a1ec37..8e2f94dd53b72 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2341,6 +2341,14 @@ function win_is_writable( $path ) { * @see wp_upload_dir() * * @return array See wp_upload_dir() for description. + * @phpstan-return array{ + * path: non-empty-string, + * url: non-empty-string, + * subdir: non-empty-string, + * basedir: non-empty-string, + * baseurl: non-empty-string, + * } + * |array{ error: non-empty-string } */ function wp_get_upload_dir() { return wp_upload_dir( null, false ); @@ -2382,6 +2390,14 @@ function wp_get_upload_dir() { * @type string $baseurl URL path without subdir. * @type string|false $error False or error message. * } + * @phpstan-return array{ + * path: non-empty-string, + * url: non-empty-string, + * subdir: non-empty-string, + * basedir: non-empty-string, + * baseurl: non-empty-string, + * } + * |array{ error: non-empty-string } */ function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false ) { static $cache = array(), $tested_paths = array(); From 18cc9e3dd687cb74bb7eaa626310e4e2386f80a0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 16:51:27 -0700 Subject: [PATCH 18/31] Add types to get_file_params()/set_file_params() in WP_REST_Request --- .../rest-api/class-wp-rest-request.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/wp-includes/rest-api/class-wp-rest-request.php b/src/wp-includes/rest-api/class-wp-rest-request.php index 7148d931f7149..a6169ada2c05b 100644 --- a/src/wp-includes/rest-api/class-wp-rest-request.php +++ b/src/wp-includes/rest-api/class-wp-rest-request.php @@ -588,6 +588,15 @@ public function set_body_params( $params ) { * @since 4.4.0 * * @return array Parameter map of key to value. + * + * @phpstan-return array, + * full_path?: non-empty-string, + * }> */ public function get_file_params() { return $this->params['FILES']; @@ -601,6 +610,15 @@ public function get_file_params() { * @since 4.4.0 * * @param array $params Parameter map of key to value. + * + * @phpstan-param array, + * full_path?: non-empty-string, + * }> $params */ public function set_file_params( $params ) { $this->params['FILES'] = $params; From 509309f252ac82a9eda2cda42216e8902874ee23 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 16:54:56 -0700 Subject: [PATCH 19/31] Add return type for WP_REST_Attachments_Controller::upload_from_data()/upload_from_file() --- .../endpoints/class-wp-rest-attachments-controller.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 9b4d0c49f7191..63aec5c9f0033 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1429,7 +1429,7 @@ public function get_item_schema() { * @param string $data Supplied file data. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. - * @return array|WP_Error Data from wp_handle_sideload(). + * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error */ protected function upload_from_data( $data, $headers, $time = null ) { if ( empty( $data ) ) { @@ -1649,7 +1649,7 @@ public function get_collection_params() { * @param array $files Data from the `$_FILES` superglobal. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. - * @return array|WP_Error Data from wp_handle_upload(). + * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_upload(). */ protected function upload_from_file( $files, $headers, $time = null ) { if ( empty( $files ) ) { From 546d4704eac476729efb5aab58eef0c0c3f07af4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:03:23 -0700 Subject: [PATCH 20/31] Add return type definition for wp_get_attachment_metadata() --- src/wp-includes/post.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 005ccadd62e34..c7b844ce743ab 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6888,6 +6888,18 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * @type array $image_meta Image metadata. * @type int $filesize File size of the attachment. * } + * + * @phpstan-return array{ + * width: int<1, max>, + * height: int<1, max>, + * file: non-empty-string, + * sizes: array, + * height: int<1, max>, + * 'mime-type': non-empty-string, + * }> + * }|false */ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) { $attachment_id = (int) $attachment_id; From 08b4fa58da807c615a0e8db67c5898fd4459b2ad Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:06:09 -0700 Subject: [PATCH 21/31] Add source_image to possible keys returned by wp_get_attachment_metadata() --- src/wp-includes/post.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index c7b844ce743ab..9094cb741c5fe 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6893,6 +6893,7 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * width: int<1, max>, * height: int<1, max>, * file: non-empty-string, + * source_image?: non-empty-string, * sizes: array, From 46bccdb2c7c77598f590379bdcdb02093dfda764 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:18:29 -0700 Subject: [PATCH 22/31] Add phpstan/phpstan-phpunit for PHPUnit assertion type narrowing Install phpstan/phpstan-phpunit (pinned to 2.0.16) and include its extension.neon so PHPUnit assertions such as assertArrayHasKey(), assertInstanceOf(), and assertNotNull() narrow types during analysis. Only extension.neon is included, not the extension's rules.neon, to avoid introducing new strict rules and the errors they would surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 1 + phpstan.neon.dist | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/composer.json b/composer.json index 6500e7ccbf8af..a309ae762ac1a 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "wp-coding-standards/wpcs": "~3.3.0", "phpcompatibility/phpcompatibility-wp": "~2.1.3", "phpstan/phpstan": "2.2.2", + "phpstan/phpstan-phpunit": "2.0.16", "yoast/phpunit-polyfills": "^1.1.0" }, "config": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e74e6ec1a441b..7ea25ab18e205 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,6 +8,12 @@ includes: # The base configuration file for using PHPStan with the WordPress core codebase. - tests/phpstan/base.neon + # Type-specifying extension so PHPUnit assertions (e.g. assertArrayHasKey(), + # assertInstanceOf(), assertNotNull()) narrow types in the analysis. Only the + # extension is included, not phpstan-phpunit's rules.neon, to avoid introducing + # new strict rules. + - vendor/phpstan/phpstan-phpunit/extension.neon + # The baseline file includes preexisting errors in the codebase that should be ignored. # https://phpstan.org/user-guide/baseline - tests/phpstan/baseline.php From 6dfdba33a474f3e50126c75f5cae8eabcd01f62f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:31:55 -0700 Subject: [PATCH 23/31] Combine sideload route tests to avoid having to re-assert shape --- .../rest-api/rest-attachments-controller.php | 57 ++++++------------- 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 75db51c319cea..5928648bb6fb1 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3330,60 +3330,35 @@ public function test_sideload_scaled_image_requires_auth() { /** * Tests that the sideload endpoint includes 'scaled' in the image_size enum. - * - * @ticket 64737 - */ - public function test_sideload_route_includes_scaled_enum() { - $this->enable_client_side_media_processing(); - - $server = rest_get_server(); - $routes = $server->get_routes(); - - $endpoint = '/wp/v2/media/(?P[\d]+)/sideload'; - $this->assertArrayHasKey( $endpoint, $routes, 'Sideload route should exist.' ); - - $route = $routes[ $endpoint ]; - $endpoint = $route[0]; - $args = $endpoint['args']; - - $param_name = 'image_size'; - $this->assertArrayHasKey( $param_name, $args, 'Route should have image_size arg.' ); - $this->assertContains( 'scaled', $args[ $param_name ]['enum'], 'image_size enum should include scaled.' ); - } - - /** * Tests that the sideload endpoint includes 'original-heic' in the image_size enum. - * - * @ticket 64915 - */ - public function test_sideload_route_includes_original_heic_enum(): void { - $this->enable_client_side_media_processing(); - - $routes = rest_get_server()->get_routes(); - $endpoint = $routes['/wp/v2/media/(?P[\d]+)/sideload'][0]; - $args = $endpoint['args']; - - $this->assertArrayHasKey( 'image_size', $args, 'Route should have image_size arg.' ); - $this->assertContains( 'original-heic', $args['image_size']['enum'], 'image_size enum should include original-heic.' ); - } - - /** * Tests that the sideload endpoint does not expose a generate_sub_sizes arg. * * sideload_item() never reads the parameter, so advertising it on the route * would silently mislead clients into expecting server-side sub-size * generation. The arg only does real work on create_item() (POST /wp/v2/media). * + * @ticket 64737 * @ticket 64915 */ - public function test_sideload_route_excludes_generate_sub_sizes_arg(): void { + public function test_sideload_route_includes_and_excludes_expected_fields(): void { $this->enable_client_side_media_processing(); - $routes = rest_get_server()->get_routes(); - $endpoint = $routes['/wp/v2/media/(?P[\d]+)/sideload'][0]; - $args = $endpoint['args']; + $routes = rest_get_server()->get_routes(); + $path = '/wp/v2/media/(?P[\d]+)/sideload'; + $this->assertArrayHasKey( $path, $routes, 'Sideload route should exist.' ); + $this->assertIsArray( $routes[ $path ] ); + $args = array_first( $routes[ $path ] ); $this->assertIsArray( $args ); + $this->assertArrayHasKey( 'image_size', $args, 'Route should have image_size arg.' ); + $this->assertIsArray( $args['image_size'] ); + $this->assertArrayHasKey( 'enum', $args['image_size'] ); + $this->assertIsArray( $args['image_size']['enum'] ); + + $this->assertContains( 'original-heic', $args['image_size']['enum'], 'image_size enum should include original-heic.' ); + + $this->assertContains( 'scaled', $args['image_size']['enum'], 'image_size enum should include scaled.' ); + $this->assertArrayNotHasKey( 'generate_sub_sizes', $args, 'Sideload route should not advertise the unused generate_sub_sizes arg.' ); } From 5b2b81b2564ee9ee99bf927c4b55e845a48471dc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:32:31 -0700 Subject: [PATCH 24/31] Add assertions that response is array with id key --- .../phpunit/tests/rest-api/rest-attachments-controller.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 5928648bb6fb1..c8eefad9c7e12 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3381,8 +3381,11 @@ public function test_sideload_original_heic_writes_metadata_source_image(): void $request->set_header( 'Content-Type', 'image/jpeg' ); $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); $request->set_body( (string) file_get_contents( self::$test_file ) ); - $response = rest_get_server()->dispatch( $request ); - $attachment_id = $response->get_data()['id']; + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'id', $data ); + $attachment_id = $data['id']; $this->assertIsInt( $attachment_id ); $this->assertSame( 201, $response->get_status() ); From 1e4203748ed7ce392ead9478d05f841a08926859 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:37:00 -0700 Subject: [PATCH 25/31] Restore return description --- .../rest-api/endpoints/class-wp-rest-attachments-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 63aec5c9f0033..e5ef6c46354cf 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1429,7 +1429,7 @@ public function get_item_schema() { * @param string $data Supplied file data. * @param array $headers HTTP headers from the request. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. - * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error + * @return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string }|WP_Error Data from wp_handle_sideload(). */ protected function upload_from_data( $data, $headers, $time = null ) { if ( empty( $data ) ) { From 707326c230d3b25e4fab6610239c2fe229b93232 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:40:15 -0700 Subject: [PATCH 26/31] Add missing nullable original_image to metadata array shape --- src/wp-includes/post.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 9094cb741c5fe..fc2bb4b5c178d 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6893,6 +6893,7 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * width: int<1, max>, * height: int<1, max>, * file: non-empty-string, + * original_image?: non-empty-string, * source_image?: non-empty-string, * sizes: array Date: Thu, 18 Jun 2026 17:40:34 -0700 Subject: [PATCH 27/31] Indicate attachment metadata shape is unsealed (since filterable) --- src/wp-includes/post.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index fc2bb4b5c178d..74200200ffc29 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6901,6 +6901,7 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * height: int<1, max>, * 'mime-type': non-empty-string, * }> + * ... * }|false */ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) { From be9dd943c0f31c9bae1997b1f7999c6fd5fda49c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:41:34 -0700 Subject: [PATCH 28/31] Fix unsealed array syntax --- src/wp-includes/post.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index 74200200ffc29..ebcf661164e0a 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6900,7 +6900,7 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * width: int<1, max>, * height: int<1, max>, * 'mime-type': non-empty-string, - * }> + * }>, * ... * }|false */ From 24676d193a7231965f0f1c3caad120d4073a4979 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Jun 2026 17:52:00 -0700 Subject: [PATCH 29/31] Make wp_get_attachment_metadata() return shape reflect all attachment types The @phpstan-return shape previously required width, height, file, and sizes, which only holds for raster images. PDFs expose just sizes and filesize, while audio/video attachments produce an entirely different set of keys. Mark those keys optional so non-image attachments are typed correctly, and add the documented-but-missing filesize and image_meta keys. Unseal the per-size arrays and add their filesize key as well, since size items ride through the same metadata filters and can carry plugin-added keys such as modern-format sources. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/post.php | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index ebcf661164e0a..ed60d42b022a5 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6890,17 +6890,35 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * } * * @phpstan-return array{ - * width: int<1, max>, - * height: int<1, max>, - * file: non-empty-string, + * width?: int<1, max>, + * height?: int<1, max>, + * file?: non-empty-string, + * filesize?: int<0, max>, * original_image?: non-empty-string, * source_image?: non-empty-string, - * sizes: array, - * height: int<1, max>, - * 'mime-type': non-empty-string, - * }>, + * sizes?: array, + * height: int<1, max>, + * 'mime-type': non-empty-string, + * filesize?: int<0, max>, + * ... + * }>, + * image_meta?: array{ + * aperture: numeric-string|int, + * credit: string, + * camera: string, + * caption: string, + * created_timestamp: numeric-string|int, + * copyright: string, + * focal_length: numeric-string|int, + * iso: numeric-string|int, + * shutter_speed: numeric-string|int, + * title: string, + * orientation: numeric-string|int, + * keywords: list, + * alt: string, + * }, * ... * }|false */ From e26b99dbe654e5e6e9bfca9cf63867cb5ad4b9b8 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 19 Jun 2026 22:11:36 +0300 Subject: [PATCH 30/31] Tests: Read sideload route args from the endpoint, not the endpoint itself test_sideload_route_includes_and_excludes_expected_fields treated the value returned by array_first( $routes[ $path ] ) as the args map, but that value is the route endpoint whose argument definitions live under its 'args' key. As a result assertArrayHasKey( 'image_size', ... ) failed on every PHP version, turning the whole PHPUnit matrix red. Descend into ['args'] first, matching the structure the sibling scaled-enum assertions already relied on. --- tests/phpunit/tests/rest-api/rest-attachments-controller.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index c8eefad9c7e12..a3fdf654ef617 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3347,7 +3347,10 @@ public function test_sideload_route_includes_and_excludes_expected_fields(): voi $path = '/wp/v2/media/(?P[\d]+)/sideload'; $this->assertArrayHasKey( $path, $routes, 'Sideload route should exist.' ); $this->assertIsArray( $routes[ $path ] ); - $args = array_first( $routes[ $path ] ); + $endpoint = array_first( $routes[ $path ] ); + $this->assertIsArray( $endpoint ); + $this->assertArrayHasKey( 'args', $endpoint, 'Route endpoint should declare args.' ); + $args = $endpoint['args']; $this->assertIsArray( $args ); $this->assertArrayHasKey( 'image_size', $args, 'Route should have image_size arg.' ); From 5f0c396c6acbd4c5e5fe2e76f61896066ce808b6 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 24 Jun 2026 17:45:36 -0700 Subject: [PATCH 31/31] REST API: Extract PHPStan type-coverage changes to a separate PR. Move the `@phpstan-return`/`@phpstan-param` annotations and the phpstan-phpunit extension out of this feature branch and into PR #12313 so the static-analysis work can be reviewed independently of the HEIC upload feature, per code-review feedback. Also correct the `$posts_clauses` test docblock: each recorded entry is the array of SQL clause fragments from the `posts_clauses` filter, so the type is `array[]`, not `string[]`. --- composer.json | 1 - phpstan.neon.dist | 6 ---- src/wp-admin/includes/file.php | 9 ----- src/wp-includes/functions.php | 16 --------- src/wp-includes/post.php | 33 ------------------- .../rest-api/class-wp-rest-request.php | 18 ---------- .../rest-api/rest-attachments-controller.php | 3 +- 7 files changed, 2 insertions(+), 84 deletions(-) diff --git a/composer.json b/composer.json index a309ae762ac1a..6500e7ccbf8af 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,6 @@ "wp-coding-standards/wpcs": "~3.3.0", "phpcompatibility/phpcompatibility-wp": "~2.1.3", "phpstan/phpstan": "2.2.2", - "phpstan/phpstan-phpunit": "2.0.16", "yoast/phpunit-polyfills": "^1.1.0" }, "config": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7ea25ab18e205..e74e6ec1a441b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,12 +8,6 @@ includes: # The base configuration file for using PHPStan with the WordPress core codebase. - tests/phpstan/base.neon - # Type-specifying extension so PHPUnit assertions (e.g. assertArrayHasKey(), - # assertInstanceOf(), assertNotNull()) narrow types in the analysis. Only the - # extension is included, not phpstan-phpunit's rules.neon, to avoid introducing - # new strict rules. - - vendor/phpstan/phpstan-phpunit/extension.neon - # The baseline file includes preexisting errors in the codebase that should be ignored. # https://phpstan.org/user-guide/baseline - tests/phpstan/baseline.php diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index d7c771444ac98..0c6d968ea02d3 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -801,9 +801,6 @@ function validate_file_to_edit( $file, $allowed_files = array() ) { * @type string $url URL of the newly-uploaded file. * @type string $type Mime type of the newly-uploaded file. * } - * - * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string } - * |array{ error: non-empty-string } */ function _wp_handle_upload( &$file, $overrides, $time, $action ) { // The default error handler. @@ -1097,9 +1094,6 @@ function wp_handle_upload_error( &$file, $message ) { * See _wp_handle_upload() for accepted values. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array See _wp_handle_upload() for return value. - * - * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string } - * |array{ error: non-empty-string } */ function wp_handle_upload( &$file, $overrides = false, $time = null ) { /* @@ -1127,9 +1121,6 @@ function wp_handle_upload( &$file, $overrides = false, $time = null ) { * See _wp_handle_upload() for accepted values. * @param string|null $time Optional. Time formatted in 'yyyy/mm'. Default null. * @return array See _wp_handle_upload() for return value. - * - * @phpstan-return array{ file: non-empty-string, url: non-empty-string, type: non-empty-string } - * |array{ error: non-empty-string } */ function wp_handle_sideload( &$file, $overrides = false, $time = null ) { /* diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 8e2f94dd53b72..355d9f8a1ec37 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -2341,14 +2341,6 @@ function win_is_writable( $path ) { * @see wp_upload_dir() * * @return array See wp_upload_dir() for description. - * @phpstan-return array{ - * path: non-empty-string, - * url: non-empty-string, - * subdir: non-empty-string, - * basedir: non-empty-string, - * baseurl: non-empty-string, - * } - * |array{ error: non-empty-string } */ function wp_get_upload_dir() { return wp_upload_dir( null, false ); @@ -2390,14 +2382,6 @@ function wp_get_upload_dir() { * @type string $baseurl URL path without subdir. * @type string|false $error False or error message. * } - * @phpstan-return array{ - * path: non-empty-string, - * url: non-empty-string, - * subdir: non-empty-string, - * basedir: non-empty-string, - * baseurl: non-empty-string, - * } - * |array{ error: non-empty-string } */ function wp_upload_dir( $time = null, $create_dir = true, $refresh_cache = false ) { static $cache = array(), $tested_paths = array(); diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index ed60d42b022a5..005ccadd62e34 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -6888,39 +6888,6 @@ function wp_delete_attachment_files( $post_id, $meta, $backup_sizes, $file ) { * @type array $image_meta Image metadata. * @type int $filesize File size of the attachment. * } - * - * @phpstan-return array{ - * width?: int<1, max>, - * height?: int<1, max>, - * file?: non-empty-string, - * filesize?: int<0, max>, - * original_image?: non-empty-string, - * source_image?: non-empty-string, - * sizes?: array, - * height: int<1, max>, - * 'mime-type': non-empty-string, - * filesize?: int<0, max>, - * ... - * }>, - * image_meta?: array{ - * aperture: numeric-string|int, - * credit: string, - * camera: string, - * caption: string, - * created_timestamp: numeric-string|int, - * copyright: string, - * focal_length: numeric-string|int, - * iso: numeric-string|int, - * shutter_speed: numeric-string|int, - * title: string, - * orientation: numeric-string|int, - * keywords: list, - * alt: string, - * }, - * ... - * }|false */ function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) { $attachment_id = (int) $attachment_id; diff --git a/src/wp-includes/rest-api/class-wp-rest-request.php b/src/wp-includes/rest-api/class-wp-rest-request.php index a6169ada2c05b..7148d931f7149 100644 --- a/src/wp-includes/rest-api/class-wp-rest-request.php +++ b/src/wp-includes/rest-api/class-wp-rest-request.php @@ -588,15 +588,6 @@ public function set_body_params( $params ) { * @since 4.4.0 * * @return array Parameter map of key to value. - * - * @phpstan-return array, - * full_path?: non-empty-string, - * }> */ public function get_file_params() { return $this->params['FILES']; @@ -610,15 +601,6 @@ public function get_file_params() { * @since 4.4.0 * * @param array $params Parameter map of key to value. - * - * @phpstan-param array, - * full_path?: non-empty-string, - * }> $params */ public function set_file_params( $params ) { $this->params['FILES'] = $params; diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index a3fdf654ef617..d63b761292568 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -53,7 +53,8 @@ class WP_Test_REST_Attachments_Controller extends WP_Test_REST_Post_Type_Control private static string $test_rtf_file; /** - * @var string[] The recorded posts query clauses. + * @var array[] The recorded posts query clauses. Each entry is the array of + * SQL clause fragments passed to the `posts_clauses` filter. */ protected array $posts_clauses;