From bf9f644f76d27f7bb1c71643491a7efaf266822e Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 20 Mar 2026 09:59:22 -0700 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 } } }