Commit a7beea4c586 for woocommerce

commit a7beea4c5860419a43e7f8b2f521e98a068b9111
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date:   Tue Nov 3 18:08:54 2026 +0000

    Fix unused files left by product image uploads (#70565)

diff --git a/plugins/woocommerce/changelog/fix-product-image-sideload-leaves-orphaned-files b/plugins/woocommerce/changelog/fix-product-image-sideload-leaves-orphaned-files
new file mode 100644
index 00000000000..60f33d0914b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-image-sideload-leaves-orphaned-files
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove rejected image uploads and unused files from failed API updates while keeping saved product images.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index 9b0b81f47dd..78a5d6dbee4 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -773,6 +773,10 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
 			$id = wc_rest_set_uploaded_image_as_attachment( $upload, $product_id );

 			if ( ! wp_attachment_is_image( $id ) ) {
+				if ( $id > 0 ) {
+					wp_delete_attachment( $id, true );
+				}
+
 				/* translators: %s: image URL */
 				throw new Exception( sprintf( __( 'Not able to attach "%s".', 'woocommerce' ), $url ), 400 );
 			}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php
index 3c88b5e41e4..5bb6612a338 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php
@@ -123,6 +123,8 @@ class WC_REST_Product_Categories_V1_Controller extends WC_REST_Terms_Controller
 		}

 		if ( isset( $request['image'] ) ) {
+			$is_new_upload = false;
+
 			if ( empty( $request['image']['id'] ) && ! empty( $request['image']['src'] ) ) {
 				$upload = wc_rest_upload_image_from_url( esc_url_raw( $request['image']['src'] ) );

@@ -130,7 +132,8 @@ class WC_REST_Product_Categories_V1_Controller extends WC_REST_Terms_Controller
 					return $upload;
 				}

-				$image_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+				$image_id      = wc_rest_set_uploaded_image_as_attachment( $upload );
+				$is_new_upload = true;
 			} else {
 				$image_id = isset( $request['image']['id'] ) ? absint( $request['image']['id'] ) : 0;
 			}
@@ -152,6 +155,10 @@ class WC_REST_Product_Categories_V1_Controller extends WC_REST_Terms_Controller
 					) );
 				}
 			} else {
+				if ( $is_new_upload && $image_id > 0 ) {
+					wp_delete_attachment( $image_id, true );
+				}
+
 				delete_term_meta( $id, 'thumbnail_id' );
 			}
 		}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
index c2737181a5b..36083d30c21 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
@@ -863,6 +863,7 @@ class WC_REST_Products_V1_Controller extends WC_REST_Posts_Controller {

 			foreach ( $images as $image ) {
 				$attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0;
+				$is_new_upload = false;

 				if ( 0 === $attachment_id && isset( $image['src'] ) ) {
 					$upload = wc_rest_upload_image_from_url( esc_url_raw( $image['src'] ) );
@@ -876,9 +877,14 @@ class WC_REST_Products_V1_Controller extends WC_REST_Posts_Controller {
 					}

 					$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload, $product->get_id() );
+					$is_new_upload = true;
 				}

 				if ( ! wp_attachment_is_image( $attachment_id ) ) {
+					if ( $is_new_upload && $attachment_id > 0 ) {
+						wp_delete_attachment( $attachment_id, true );
+					}
+
 					throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
 				}

diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index eba2d1add83..111bbe1a9d3 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -1473,6 +1473,7 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {

 			foreach ( $images as $index => $image ) {
 				$attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0;
+				$is_new_upload = false;

 				if ( 0 === $attachment_id && isset( $image['src'] ) ) {
 					$upload = wc_rest_upload_image_from_url( esc_url_raw( $image['src'] ) );
@@ -1486,9 +1487,14 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
 					}

 					$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload, $product->get_id() );
+					$is_new_upload = true;
 				}

 				if ( ! wp_attachment_is_image( $attachment_id ) ) {
+					if ( $is_new_upload && $attachment_id > 0 ) {
+						wp_delete_attachment( $attachment_id, true );
+					}
+
 					/* translators: %s: attachment id */
 					throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
 				}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php
index b81c13b85fa..84247a88c4e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php
@@ -231,6 +231,8 @@ class WC_REST_Product_Categories_Controller extends WC_REST_Product_Categories_V
 		}

 		if ( isset( $request['image'] ) ) {
+			$is_new_upload = false;
+
 			if ( empty( $request['image']['id'] ) && ! empty( $request['image']['src'] ) ) {
 				$upload = wc_rest_upload_image_from_url( esc_url_raw( $request['image']['src'] ) );

@@ -238,7 +240,8 @@ class WC_REST_Product_Categories_Controller extends WC_REST_Product_Categories_V
 					return $upload;
 				}

-				$image_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+				$image_id      = wc_rest_set_uploaded_image_as_attachment( $upload );
+				$is_new_upload = true;
 			} else {
 				$image_id = isset( $request['image']['id'] ) ? absint( $request['image']['id'] ) : 0;
 			}
@@ -262,6 +265,10 @@ class WC_REST_Product_Categories_Controller extends WC_REST_Product_Categories_V
 					);
 				}
 			} else {
+				if ( $is_new_upload && $image_id > 0 ) {
+					wp_delete_attachment( $image_id, true );
+				}
+
 				delete_term_meta( $id, 'thumbnail_id' );
 			}
 		}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 231cbeaba3e..5d7e9bfcd4f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -516,6 +516,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 	 */
 	protected function set_variation_image( $variation, $image ) {
 		$attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0;
+		$is_new_upload = false;

 		if ( 0 === $attachment_id ) {
 			if ( isset( $image['src'] ) ) {
@@ -537,6 +538,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 				}

 				$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload, $variation->get_id() );
+				$is_new_upload = true;
 			} else {
 				$variation->set_image_id( '' );
 				return $variation;
@@ -544,6 +546,10 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 		}

 		if ( ! wp_attachment_is_image( $attachment_id ) ) {
+			if ( $is_new_upload && $attachment_id > 0 ) {
+				wp_delete_attachment( $attachment_id, true );
+			}
+
 			/* translators: %s: attachment ID */
 			throw new WC_REST_Exception( 'woocommerce_variation_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
 		}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 9327a035c8e..86b1d783a62 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
 use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;
+use Automattic\WooCommerce\Internal\RestApi\ProductImageUploadCleanupTrait;
 use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Utilities\I18nUtil;
@@ -31,6 +32,7 @@ defined( 'ABSPATH' ) || exit;
 class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {

 	use CogsAwareRestControllerTrait;
+	use ProductImageUploadCleanupTrait;
 	use ProductRequestPreparationTrait;

 	/**
@@ -89,13 +91,6 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 	 */
 	private $exclude_status = array();

-	/**
-	 * Stores attachment IDs processed during the current request for potential cleanup.
-	 *
-	 * @var array
-	 */
-	private $processed_attachment_ids_for_request = array();
-
 	/**
 	 * Register the routes for products.
 	 */
@@ -755,6 +750,10 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 				}

 				if ( ! wp_attachment_is_image( $attachment_id ) ) {
+					if ( $is_new_upload && $attachment_id > 0 ) {
+						wp_delete_attachment( $attachment_id, true );
+					}
+
 					/* translators: %s: image ID */
 					throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
 				}
diff --git a/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php b/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php
index debecff9f74..f2f0a046488 100644
--- a/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php
+++ b/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php
@@ -57,6 +57,10 @@ class ImageAttachment {
 		$this->id = wc_rest_set_uploaded_image_as_attachment( $upload, $this->object_id );

 		if ( ! wp_attachment_is_image( $this->id ) ) {
+			if ( $this->id > 0 ) {
+				wp_delete_attachment( $this->id, true );
+			}
+
 			/* translators: %s: image ID */
 			throw new \WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $this->id ), 400 );
 		}
diff --git a/plugins/woocommerce/includes/wc-rest-functions.php b/plugins/woocommerce/includes/wc-rest-functions.php
index deeb5ed1e65..19ec1510372 100644
--- a/plugins/woocommerce/includes/wc-rest-functions.php
+++ b/plugins/woocommerce/includes/wc-rest-functions.php
@@ -87,6 +87,7 @@ function wc_rest_upload_image_from_url( $image_url ) {

 	$file_array         = array();
 	$file_array['name'] = basename( current( explode( '?', $image_url ) ) );
+	$file_array['type'] = ''; // WordPress uses this fallback for unfiltered uploads without a recognized type.

 	// Download file to temp location.
 	$file_array['tmp_name'] = download_url( $image_url );
@@ -161,8 +162,10 @@ function wc_rest_set_uploaded_image_as_attachment( $upload, $id = 0 ) {
 	);

 	$attachment_id = wp_insert_attachment( $attachment, $upload['file'], $id );
-	if ( ! is_wp_error( $attachment_id ) ) {
+	if ( $attachment_id && ! is_wp_error( $attachment_id ) ) {
 		@wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $upload['file'] ) );
+	} else {
+		wp_delete_file( $upload['file'] );
 	}

 	return $attachment_id;
diff --git a/plugins/woocommerce/src/Internal/RestApi/ProductImageUploadCleanupTrait.php b/plugins/woocommerce/src/Internal/RestApi/ProductImageUploadCleanupTrait.php
new file mode 100644
index 00000000000..3ab40b13f13
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/ProductImageUploadCleanupTrait.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Product REST image upload cleanup.
+ *
+ * @package WooCommerce\Internal\RestApi
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi;
+
+use WC_Product;
+use WP_Error;
+use WP_REST_Request;
+use WP_REST_Response;
+
+/**
+ * Track new product images and remove unused uploads after a failed update.
+ */
+trait ProductImageUploadCleanupTrait {
+
+	/**
+	 * Stores attachment IDs processed during the current request for potential cleanup.
+	 *
+	 * @var int[]
+	 */
+	private $processed_attachment_ids_for_request = array();
+
+	/**
+	 * Update a product and remove new images that the saved product does not use on failure.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WP_REST_Request<array> $request Request object.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function update_item( $request ) {
+		$this->processed_attachment_ids_for_request = array();
+
+		try {
+			$response = parent::update_item( $request );
+
+			if ( is_wp_error( $response ) ) {
+				$this->delete_unused_uploaded_images( (int) $request['id'] );
+			}
+
+			return $response;
+		} finally {
+			$this->processed_attachment_ids_for_request = array();
+		}
+	}
+
+	/**
+	 * Remove newly uploaded images that the persisted product does not use.
+	 *
+	 * @param int $product_id Product ID.
+	 */
+	private function delete_unused_uploaded_images( int $product_id ): void {
+		if ( ! $this->processed_attachment_ids_for_request ) {
+			return;
+		}
+
+		// An error can occur after saving, so read the persisted image references.
+		$product        = $this->get_object( $product_id );
+		$used_image_ids = $product instanceof WC_Product ? array_merge( array( $product->get_image_id( 'edit' ) ), $product->get_gallery_image_ids( 'edit' ) ) : array();
+
+		foreach ( array_diff( $this->processed_attachment_ids_for_request, $used_image_ids ) as $attachment_id ) {
+			wp_delete_attachment( (int) $attachment_id, true );
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index 2c7b566b489..bab21f2c402 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -20,6 +20,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\WeightUnit;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\RestApi\ProductImageUploadCleanupTrait;
 use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Utilities\I18nUtil;
@@ -46,6 +47,7 @@ defined( 'ABSPATH' ) || exit;
 class Controller extends WC_REST_Products_V2_Controller {

 	use CogsAwareRestControllerTrait;
+	use ProductImageUploadCleanupTrait;
 	use ProductRequestPreparationTrait;

 	/**
@@ -116,13 +118,6 @@ class Controller extends WC_REST_Products_V2_Controller {
 	 */
 	private $stock_quantity_filter = array();

-	/**
-	 * Stores attachment IDs processed during the current request for potential cleanup.
-	 *
-	 * @var array
-	 */
-	private $processed_attachment_ids_for_request = array();
-
 	/**
 	 * Register the routes for products.
 	 */
@@ -971,6 +966,10 @@ class Controller extends WC_REST_Products_V2_Controller {
 				}

 				if ( ! wp_attachment_is_image( $attachment_id ) ) {
+					if ( $is_new_upload && $attachment_id > 0 ) {
+						wp_delete_attachment( $attachment_id, true );
+					}
+
 					/* translators: %s: image ID */
 					throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
 				}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php
new file mode 100644
index 00000000000..b4b0bf1ec35
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php
@@ -0,0 +1,385 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for cleanup of image downloads used by REST writes.
+ *
+ * @package WooCommerce\Tests\RestApi
+ */
+class WC_REST_Image_Upload_Cleanup_Test extends WC_REST_Unit_Test_Case {
+	/** @var array Downloaded files, including files with no attachment. */
+	private $uploads = array();
+
+	/** @var int[] Attachments whose physical files need teardown. */
+	private $attachment_ids = array();
+
+	/** @var string Contents served by the fake download transport. */
+	private $download_content;
+
+	/**
+	 * Set up a real upload pipeline with a local download fixture.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$this->download_content = file_get_contents( WC_ABSPATH . 'tests/legacy/data/Dr1Bczxq4q.png' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local test fixture.
+
+		add_filter(
+			'pre_http_request',
+			function ( $response, $args, $url ) {
+				if ( 'example.org' !== wp_parse_url( $url, PHP_URL_HOST ) ) {
+					return $response;
+				}
+				file_put_contents( $args['filename'], $this->download_content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Emulate download_url writing its temporary file.
+				return array(
+					'headers'  => array(),
+					'body'     => '',
+					'response' => array(
+						'code'    => 200,
+						'message' => 'OK',
+					),
+				);
+			},
+			10,
+			3
+		);
+		add_action(
+			'woocommerce_rest_api_uploaded_image_from_url',
+			function ( $upload ) {
+				$this->uploads[] = $upload;
+			}
+		);
+		add_action(
+			'add_attachment',
+			function ( $attachment_id ) {
+				$this->attachment_ids[] = $attachment_id;
+			}
+		);
+	}
+
+	/**
+	 * Remove physical files, which database rollback cannot remove.
+	 */
+	public function tearDown(): void {
+		try {
+			foreach ( $this->attachment_ids as $attachment_id ) {
+				wp_delete_attachment( $attachment_id, true );
+			}
+			foreach ( $this->uploads as $upload ) {
+				if ( file_exists( $upload['file'] ) ) {
+					wp_delete_file( $upload['file'] );
+				}
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * One request per image-setting implementation; inherited versions share these paths.
+	 *
+	 * @return array
+	 */
+	public static function provide_write_requests(): array {
+		return array(
+			'products v1'   => array( 'products', 1, 'POST' ),
+			'products v2'   => array( 'products', 2, 'POST' ),
+			'products v3'   => array( 'products', 3, 'POST' ),
+			'products v4'   => array( 'products', 4, 'POST' ),
+			'variations v3' => array( 'variations', 3, 'PUT' ),
+			'categories v1' => array( 'categories', 1, 'POST' ),
+			'categories v3' => array( 'categories', 3, 'PUT' ),
+		);
+	}
+
+	/**
+	 * Build a request with the minimum parent or existing object it needs.
+	 *
+	 * @param string $kind Object kind.
+	 * @param int    $version API version.
+	 * @param string $method HTTP method.
+	 * @param array  $image Image request data.
+	 * @return WP_REST_Request
+	 */
+	private function image_request( string $kind, int $version, string $method, array $image ): WP_REST_Request {
+		$route = "/wc/v$version/products";
+		if ( 4 === $version ) {
+			$sut = new Automattic\WooCommerce\Internal\RestApi\Routes\V4\Products\Controller();
+			$sut->register_routes();
+		}
+
+		if ( 'categories' === $kind ) {
+			$route .= '/categories';
+			if ( 'PUT' === $method ) {
+				$term   = wp_insert_term( 'Existing category', 'product_cat' );
+				$route .= '/' . $term['term_id'];
+			}
+		} elseif ( 'variations' === $kind ) {
+			$product = new WC_Product_Variable();
+			$product->save();
+			$route .= '/' . $product->get_id() . '/variations';
+			if ( 'PUT' === $method ) {
+				$variation = new WC_Product_Variation();
+				$variation->set_parent_id( $product->get_id() );
+				$route .= '/' . $variation->save();
+			}
+		} elseif ( 'PUT' === $method ) {
+			$product = new WC_Product_Simple();
+			$route  .= '/' . $product->save();
+		}
+
+		$request = new WP_REST_Request( $method, $route );
+		$request->set_param( 'name', 'Image cleanup test' );
+		$request->set_param( 'products' === $kind ? 'images' : 'image', 'products' === $kind ? array( $image ) : $image );
+		return $request;
+	}
+
+	/**
+	 * Enable only this test's unfiltered upload capability without a global constant.
+	 */
+	private function allow_unfiltered_uploads(): void {
+		add_filter(
+			'map_meta_cap',
+			static function ( $caps, $cap ) {
+				return 'unfiltered_upload' === $cap ? array( 'exist' ) : $caps;
+			},
+			10,
+			2
+		);
+	}
+
+	/**
+	 * @testdox Rejected new non-images are deleted even when the category write succeeds.
+	 * @dataProvider provide_write_requests
+	 *
+	 * @param string $kind Object kind.
+	 * @param int    $version API version.
+	 * @param string $method HTTP method.
+	 */
+	public function test_rejected_download_is_deleted( string $kind, int $version, string $method ): void {
+		$this->allow_unfiltered_uploads();
+		$this->download_content = 'Inert non-image fixture, with no executable code.';
+		$request                = $this->image_request( $kind, $version, $method, array( 'src' => 'https://example.org/rejected.php' ) );
+		$response               = $this->server->dispatch( $request );
+
+		$this->assertSame( 'categories' === $kind ? ( 'POST' === $method ? 201 : 200 ) : 400, $response->get_status() );
+		$this->assertCount( 1, $this->uploads, 'The upload must reach the real sideload handler.' );
+		$this->assertCount( 1, $this->attachment_ids );
+		$this->assertFileDoesNotExist( $this->uploads[0]['file'] );
+		$this->assertNull( get_post( $this->attachment_ids[0] ) );
+	}
+
+	/**
+	 * @testdox The default upload policy rejects PHP before it reaches the uploads directory.
+	 */
+	public function test_default_policy_rejects_non_image(): void {
+		$this->download_content = 'Inert non-image fixture.';
+		$request                = $this->image_request( 'products', 3, 'POST', array( 'src' => 'https://example.org/rejected.php' ) );
+		$response               = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'woocommerce_product_image_upload_error', $response->get_data()['code'] );
+		$this->assertSame( array(), $this->uploads );
+		$this->assertSame( array(), $this->attachment_ids );
+	}
+
+	/**
+	 * @testdox Other image-helper callers also delete their rejected new downloads.
+	 * @testWith ["utility"]
+	 *           ["importer"]
+	 *
+	 * @param string $caller Image upload caller.
+	 */
+	public function test_other_callers_delete_rejected_download( string $caller ): void {
+		$this->allow_unfiltered_uploads();
+		$this->download_content = 'Inert non-image fixture.';
+		$error                  = null;
+
+		try {
+			if ( 'utility' === $caller ) {
+				$sut = new Automattic\WooCommerce\RestApi\Utilities\ImageAttachment();
+				$sut->upload_image_from_src( 'https://example.org/rejected.php' );
+			} else {
+				require_once WC_ABSPATH . 'includes/import/abstract-wc-product-importer.php';
+				require_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
+				$sut = new WC_Product_CSV_Importer( WC_ABSPATH . 'tests/legacy/unit-tests/importer/sample.csv' );
+				$sut->get_attachment_id_from_url( 'https://example.org/rejected.php', 0 );
+			}
+		} catch ( Exception $exception ) {
+			$error = $exception;
+		}
+
+		$this->assertInstanceOf( Exception::class, $error );
+		$this->assertCount( 1, $this->uploads );
+		$this->assertCount( 1, $this->attachment_ids );
+		$this->assertFileDoesNotExist( $this->uploads[0]['file'] );
+		$this->assertNull( get_post( $this->attachment_ids[0] ) );
+	}
+
+	/**
+	 * @testdox An unsuccessful attachment insert removes its otherwise untracked file.
+	 */
+	public function test_failed_attachment_insert_removes_file(): void {
+		add_filter(
+			'wp_insert_post_empty_content',
+			static function ( $is_empty, $post ) {
+				return 'attachment' === $post['post_type'] ? true : $is_empty;
+			},
+			10,
+			2
+		);
+		$upload = wc_rest_upload_image_from_url( 'https://example.org/insert-failure.png' );
+		$this->assertIsArray( $upload );
+		$this->assertFileExists( $upload['file'] );
+
+		$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+
+		$this->assertSame( 0, $attachment_id );
+		$this->assertSame( array(), $this->attachment_ids );
+		$this->assertFileDoesNotExist( $upload['file'] );
+	}
+
+	/**
+	 * @testdox Rejecting an existing non-image never deletes it from the media library.
+	 * @dataProvider provide_write_requests
+	 *
+	 * @param string $kind Object kind.
+	 * @param int    $version API version.
+	 * @param string $method HTTP method.
+	 */
+	public function test_existing_non_image_is_preserved( string $kind, int $version, string $method ): void {
+		$upload          = wp_upload_bits( 'existing-manual.pdf', null, 'Existing PDF fixture' );
+		$this->uploads[] = $upload;
+		$attachment_id   = wp_insert_attachment( array( 'post_mime_type' => 'application/pdf' ), $upload['file'] );
+		$request         = $this->image_request( $kind, $version, $method, array( 'id' => $attachment_id ) );
+		$response        = $this->server->dispatch( $request );
+
+		$this->assertSame( 'categories' === $kind ? ( 'POST' === $method ? 201 : 200 ) : 400, $response->get_status() );
+		$this->assertInstanceOf( WP_Post::class, get_post( $attachment_id ) );
+		$this->assertFileExists( $upload['file'] );
+	}
+
+	/**
+	 * @testdox A valid PNG upload remains usable after a successful write.
+	 * @dataProvider provide_write_requests
+	 *
+	 * @param string $kind Object kind.
+	 * @param int    $version API version.
+	 * @param string $method HTTP method.
+	 */
+	public function test_valid_image_is_preserved( string $kind, int $version, string $method ): void {
+		$request  = $this->image_request(
+			$kind,
+			$version,
+			$method,
+			array(
+				'src'      => 'https://example.org/accepted.png',
+				'position' => 0,
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 'POST' === $method ? 201 : 200, $response->get_status() );
+		$this->assertCount( 1, $this->attachment_ids );
+		$this->assertFileExists( $this->uploads[0]['file'] );
+		$data  = $response->get_data();
+		$image = 'products' === $kind ? $data['images'][0] : $data['image'];
+		$this->assertSame( $this->attachment_ids[0], $image['id'] );
+	}
+
+	/**
+	 * @testdox An SVG allowed by the existing upload filters remains accepted.
+	 * @testWith ["products", 3, "POST"]
+	 *           ["variations", 3, "POST"]
+	 *           ["categories", 3, "POST"]
+	 *
+	 * @param string $kind Object kind.
+	 * @param int    $version API version.
+	 * @param string $method HTTP method.
+	 */
+	public function test_allowed_svg_is_preserved( string $kind, int $version, string $method ): void {
+		$allow_svg = static function ( $mimes ) {
+			$mimes['svg'] = 'image/svg+xml';
+			return $mimes;
+		};
+		add_filter( 'upload_mimes', $allow_svg );
+		add_filter( 'woocommerce_rest_allowed_image_mime_types', $allow_svg );
+		// Older Fileinfo versions need the XML declaration to recognize the SVG MIME type.
+		$this->download_content = '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
+		$request                = $this->image_request( $kind, $version, $method, array( 'src' => 'https://example.org/accepted.svg' ) );
+		$response               = $this->server->dispatch( $request );
+
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertCount( 1, $this->attachment_ids );
+		$this->assertSame( 'image/svg+xml', get_post_mime_type( $this->attachment_ids[0] ) );
+		$this->assertFileExists( $this->uploads[0]['file'] );
+	}
+
+	/**
+	 * @testdox A failed update removes unused new images and preserves an existing library file.
+	 * @testWith [3]
+	 *           [4]
+	 *
+	 * @param int $version API version.
+	 */
+	public function test_failed_update_cleans_only_unused_new_images( int $version ): void {
+		$existing_id = self::factory()->attachment->create( array( 'post_mime_type' => 'application/pdf' ) );
+		$request     = $this->image_request( 'products', $version, 'PUT', array() );
+		$request->set_param(
+			'images',
+			array(
+				array( 'src' => 'https://example.org/featured.png' ),
+				array( 'src' => 'https://example.org/gallery.png' ),
+				array( 'id' => $existing_id ),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertCount( 2, $this->uploads );
+		foreach ( $this->uploads as $upload ) {
+			$this->assertFileDoesNotExist( $upload['file'] );
+		}
+		foreach ( array_diff( $this->attachment_ids, array( $existing_id ) ) as $attachment_id ) {
+			$this->assertNull( get_post( $attachment_id ) );
+		}
+		$this->assertInstanceOf( WP_Post::class, get_post( $existing_id ) );
+	}
+
+	/**
+	 * @testdox An error after saving preserves the persisted featured and gallery images.
+	 * @testWith [3]
+	 *           [4]
+	 *
+	 * @param int $version API version.
+	 */
+	public function test_error_after_save_preserves_adopted_images( int $version ): void {
+		$request = $this->image_request( 'products', $version, 'PUT', array() );
+		$request->set_param(
+			'images',
+			array(
+				array( 'src' => 'https://example.org/featured.png' ),
+				array( 'src' => 'https://example.org/gallery.png' ),
+			)
+		);
+		add_action(
+			'woocommerce_rest_insert_product_object',
+			static function () {
+				throw new WC_REST_Exception( 'test_after_save', 'Failure after saving.', 400 );
+			}
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'test_after_save', $response->get_data()['code'] );
+		$this->assertCount( 2, $this->uploads );
+		$this->assertSame( $this->attachment_ids[0], (int) get_post_meta( $request['id'], '_thumbnail_id', true ) );
+		$this->assertSame( (string) $this->attachment_ids[1], get_post_meta( $request['id'], '_product_image_gallery', true ) );
+		foreach ( $this->uploads as $upload ) {
+			$this->assertFileExists( $upload['file'] );
+		}
+		foreach ( $this->attachment_ids as $attachment_id ) {
+			$this->assertInstanceOf( WP_Post::class, get_post( $attachment_id ) );
+		}
+	}
+}