Commit 07ebe8af79 for woocommerce

commit 07ebe8af79bc9e3a176874f09bdb0ad472cf3ae6
Author: Naman Malhotra <naman03malhotra@gmail.com>
Date:   Tue Nov 25 08:57:42 2025 +0530

    [Migrator]: Fix the tax migration and add XSS filtering (#61453)

diff --git a/plugins/woocommerce/changelog/fix-migrator-cft b/plugins/woocommerce/changelog/fix-migrator-cft
new file mode 100644
index 0000000000..ad7aa1592a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-migrator-cft
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fixes for Migrator CLI
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php
index edd1c1f096..2ad87806e3 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php
@@ -175,8 +175,21 @@ class ProductsController {
 			: 'Importing Products from ' . ucfirst( $this->parsed_args['platform'] );
 		$progress       = \WP_CLI\Utils\make_progress_bar( $progress_label, $total_count );

+		// Set initial progress - either show resumed progress or 1% for new sessions.
+		$initial_tick = max( 1, (int) ceil( $total_count * 0.01 ) );
+
 		if ( ! $this->parsed_args['dry_run'] ) {
-			$progress->tick( $this->session->count_all_imported_entities() );
+			$already_imported = $this->session->count_all_imported_entities();
+			if ( $already_imported > 0 ) {
+				// Show actual resumed progress.
+				$progress->tick( $already_imported );
+			} else {
+				// Show 1% for new sessions to indicate activity has started.
+				$progress->tick( $initial_tick );
+			}
+		} else {
+			// For dry runs, show initial 1% tick.
+			$progress->tick( $initial_tick );
 		}

 		$this->configure_product_importer();
@@ -545,6 +558,13 @@ class ProductsController {
 			WP_CLI::line( sprintf( '  Last Cursor: %s', substr( $session->get_reentrancy_cursor(), 0, 50 ) . '...' ) );
 		}

+		$original_args = $session->get_original_arguments();
+		if ( $original_args ) {
+			WP_CLI::line( '' );
+			WP_CLI::line( WP_CLI::colorize( '%YOriginal Command Arguments:%n' ) );
+			$this->display_saved_arguments( $original_args );
+		}
+
 		WP_CLI::line( '' );

 		$should_resume = $parsed_args['resume'] ?? false;
@@ -561,6 +581,13 @@ class ProductsController {

 		if ( $should_resume ) {
 			WP_CLI::success( sprintf( 'Resuming migration session %d', $session->get_id() ) );
+
+			$original_args = $session->get_original_arguments();
+			if ( $original_args ) {
+				$this->restore_original_arguments( $original_args );
+				WP_CLI::line( 'Original command arguments have been restored.' );
+			}
+
 			return $session;
 		} else {
 			$session->archive();
@@ -595,6 +622,8 @@ class ProductsController {
 				)
 			);

+			$session->set_original_arguments( $parsed_args );
+
 			return $session;

 		} catch ( Exception $e ) {
@@ -969,4 +998,66 @@ class ProductsController {
 	protected function get_user_input(): string {
 		return strtolower( trim( fgets( STDIN ) ) );
 	}
+
+	/**
+	 * Display the saved arguments from a previous session.
+	 *
+	 * @param array $args The saved arguments to display.
+	 */
+	private function display_saved_arguments( array $args ): void {
+		$important_args = array(
+			'platform'                => 'Platform',
+			'limit'                   => 'Product Limit',
+			'batch_size'              => 'Batch Size',
+			'skip_existing'           => 'Skip Existing',
+			'dry_run'                 => 'Dry Run',
+			'verbose'                 => 'Verbose',
+			'assign_default_category' => 'Assign Default Category',
+		);
+
+		foreach ( $important_args as $key => $label ) {
+			if ( isset( $args[ $key ] ) ) {
+				$value = $args[ $key ];
+				if ( is_bool( $value ) ) {
+					$value = $value ? 'Yes' : 'No';
+				} elseif ( is_array( $value ) ) {
+					$value = implode( ', ', $value );
+				} elseif ( 'limit' === $key && PHP_INT_MAX === (int) $value ) {
+					$value = 'All';
+				}
+				WP_CLI::line( sprintf( '  %s: %s', $label, $value ) );
+			}
+		}
+
+		if ( ! empty( $args['filters'] ) && is_array( $args['filters'] ) ) {
+			WP_CLI::line( '  Filters:' );
+			foreach ( $args['filters'] as $filter_key => $filter_value ) {
+				if ( is_array( $filter_value ) ) {
+					$filter_value = implode( ', ', $filter_value );
+				}
+				WP_CLI::line( sprintf( '    %s: %s', $filter_key, $filter_value ) );
+			}
+		}
+
+		if ( ! empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
+			WP_CLI::line( sprintf( '  Fields: %s', implode( ', ', $args['fields'] ) ) );
+		}
+	}
+
+	/**
+	 * Restore the original arguments to the current parsed args.
+	 *
+	 * @param array $original_args The original arguments to restore.
+	 */
+	private function restore_original_arguments( array $original_args ): void {
+		foreach ( $original_args as $key => $value ) {
+			if ( 'resume' !== $key ) {
+				$this->parsed_args[ $key ] = $value;
+			}
+		}
+
+		if ( isset( $original_args['fields'] ) ) {
+			$this->fields_to_process = $original_args['fields'];
+		}
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
index 02d39141c3..bfff8ff7b8 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
@@ -492,6 +492,10 @@ class WooCommerceProductImporter {
 			$product->set_weight( $product_data['weight'] );
 		}

+		if ( ! empty( $product_data['tax_status'] ) ) {
+			$product->set_tax_status( $product_data['tax_status'] );
+		}
+
 		if ( ! empty( $product_data['metafields'] ) ) {
 			foreach ( $product_data['metafields'] as $key => $value ) {
 				if ( ! empty( $key ) ) {
@@ -805,6 +809,10 @@ class WooCommerceProductImporter {

 			$variation->set_weight( $var_data['weight'] ?? '' );

+			if ( ! empty( $var_data['tax_status'] ) ) {
+				$variation->set_tax_status( $var_data['tax_status'] );
+			}
+
 			$image_original_id = $var_data['image_original_id'] ?? null;
 			if ( $image_original_id && isset( $this->migration_data['images_mapping'][ $image_original_id ] ) ) {
 				$variation->set_image_id( $this->migration_data['images_mapping'][ $image_original_id ] );
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php
index baa96f79db..67e695c7e4 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php
@@ -671,4 +671,23 @@ class ImportSession {
 		// requires an addslashes() call to preserve them.
 		update_post_meta( $this->post_id, 'importer_cursor', addslashes( $cursor ) );
 	}
-}
\ No newline at end of file
+
+	/**
+	 * Save the original command arguments for session resumption.
+	 *
+	 * @param array $args The original command arguments
+	 */
+	public function set_original_arguments( array $args ) {
+		update_post_meta( $this->post_id, 'original_arguments', $args );
+	}
+
+	/**
+	 * Get the original command arguments for session resumption.
+	 *
+	 * @return array|null The original arguments or null if not found
+	 */
+	public function get_original_arguments() {
+		$args = get_post_meta( $this->post_id, 'original_arguments', true );
+		return ( is_array( $args ) && ! empty( $args ) ) ? $args : null;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php
index 0d37f289e2..cf65d3ba21 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php
@@ -84,6 +84,7 @@ class ShopifyFetcher implements PlatformFetcherInterface {
 								price
 								compareAtPrice
 								sku
+								taxable
 								inventoryPolicy
 								inventoryQuantity
 								position
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php
index 0ae9eefd36..73dc10db00 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php
@@ -246,8 +246,8 @@ class ShopifyMapper implements PlatformMapperInterface {
 		foreach ( $shopify_product->collections->edges as $collection_edge ) {
 			$collection_node = $collection_edge->node;
 			$categories[]    = array(
-				'name' => $collection_node->title,
-				'slug' => $collection_node->handle,
+				'name' => wc_clean( $collection_node->title ),
+				'slug' => sanitize_title( $collection_node->handle ),
 			);
 		}

@@ -270,7 +270,7 @@ class ShopifyMapper implements PlatformMapperInterface {
 			$trimmed_tag = trim( $tag );
 			if ( ! empty( $trimmed_tag ) ) {
 				$tags[] = array(
-					'name' => $trimmed_tag,
+					'name' => wc_clean( $trimmed_tag ),
 					'slug' => sanitize_title( $trimmed_tag ),
 				);
 			}
@@ -320,15 +320,6 @@ class ShopifyMapper implements PlatformMapperInterface {
 		return (float) $weight * self::WEIGHT_CONVERSION_FACTORS[ $shopify_unit_key ][ $store_weight_unit ];
 	}

-	/**
-	 * Basic sanitization for product description HTML.
-	 *
-	 * @param string $html Raw description HTML.
-	 * @return string Sanitized HTML.
-	 */
-	private function sanitize_product_description( string $html ): string {
-		return trim( $html );
-	}

 	/**
 	 * Checks if a specific field should be processed based on constructor args.
@@ -357,10 +348,10 @@ class ShopifyMapper implements PlatformMapperInterface {
 		$basic_data['original_product_id'] = ! empty( $shopify_product->id ) ? basename( $shopify_product->id ) : null;

 		// Basic Product Fields.
-		$basic_data['name']              = $shopify_product->title;
-		$basic_data['slug']              = $shopify_product->handle;
-		$basic_data['description']       = $this->sanitize_product_description( $shopify_product->descriptionHtml ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
-		$basic_data['short_description'] = $shopify_product->descriptionPlainSummary ?? ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
+		$basic_data['name']              = wc_clean( $shopify_product->title );
+		$basic_data['slug']              = sanitize_title( $shopify_product->handle );
+		$basic_data['description']       = wp_kses_post( $shopify_product->descriptionHtml ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
+		$basic_data['short_description'] = wp_kses_post( $shopify_product->descriptionPlainSummary ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
 		$basic_data['status']            = $this->get_woo_product_status( $shopify_product );
 		$basic_data['date_created_gmt']  = $shopify_product->createdAt; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.

@@ -394,7 +385,7 @@ class ShopifyMapper implements PlatformMapperInterface {
 		// Brand (Vendor).
 		$brand_name          = $shopify_product->vendor ?? null;
 		$basic_data['brand'] = $brand_name ? array(
-			'name' => $brand_name,
+			'name' => wc_clean( $brand_name ),
 			'slug' => sanitize_title( $brand_name ),
 		) : null;

@@ -424,7 +415,7 @@ class ShopifyMapper implements PlatformMapperInterface {
 			}

 			if ( $this->should_process( 'sku' ) ) {
-				$simple_data['sku'] = $variant_node->sku;
+				$simple_data['sku'] = wc_clean( $variant_node->sku );
 			}

 			if ( $this->should_process( 'stock' ) ) {
@@ -455,17 +446,25 @@ class ShopifyMapper implements PlatformMapperInterface {
 				$simple_data['cost_of_goods'] = $variant_node->inventoryItem->unitCost->amount; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
 			}

+			if ( property_exists( $variant_node, 'taxable' ) ) {
+				$simple_data['tax_status'] = $variant_node->taxable ? 'taxable' : 'none';
+			}
+
 			$simple_data['original_variant_id'] = ! empty( $variant_node->id ) ? basename( $variant_node->id ) : null;

 		} else {
-			// Defaults for variable or product with no variants.
-			$simple_data['sku']                 = null;
-			$simple_data['regular_price']       = null;
-			$simple_data['sale_price']          = null;
-			$simple_data['stock_quantity']      = null;
-			$simple_data['manage_stock']        = false;
-			$simple_data['stock_status']        = 'instock';
-			$simple_data['weight']              = null;
+			$simple_data['sku']            = null;
+			$simple_data['regular_price']  = null;
+			$simple_data['sale_price']     = null;
+			$simple_data['stock_quantity'] = null;
+			$simple_data['manage_stock']   = false;
+			$simple_data['stock_status']   = 'instock';
+			$simple_data['weight']         = null;
+
+			if ( property_exists( $shopify_product, 'taxable' ) ) {
+				$simple_data['tax_status'] = $shopify_product->taxable ? 'taxable' : 'none';
+			}
+
 			$simple_data['original_variant_id'] = null;
 		}

@@ -487,8 +486,8 @@ class ShopifyMapper implements PlatformMapperInterface {
 		if ( $is_variable && property_exists( $shopify_product, 'options' ) && ! empty( $shopify_product->options ) ) {
 			foreach ( $shopify_product->options as $option ) {
 				$variable_data['attributes'][] = array(
-					'name'         => $option->name,
-					'options'      => $option->values,
+					'name'         => wc_clean( $option->name ),
+					'options'      => array_map( 'wc_clean', $option->values ),
 					'position'     => $option->position,
 					'is_visible'   => true,
 					'is_variation' => true,
@@ -515,7 +514,7 @@ class ShopifyMapper implements PlatformMapperInterface {
 				}

 				if ( $this->should_process( 'sku' ) ) {
-					$variation_data['sku'] = $variant_node->sku ?? null;
+					$variation_data['sku'] = wc_clean( $variant_node->sku ?? '' );
 				}

 				if ( $this->should_process( 'stock' ) ) {
@@ -546,11 +545,15 @@ class ShopifyMapper implements PlatformMapperInterface {
 					$variation_data['cost_of_goods'] = $variant_node->inventoryItem->unitCost->amount; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
 				}

+				if ( property_exists( $variant_node, 'taxable' ) ) {
+					$variation_data['tax_status'] = $variant_node->taxable ? 'taxable' : 'none';
+				}
+
 				if ( $this->should_process( 'attributes' ) ) {
 					$variation_data['attributes'] = array();
 					if ( ! empty( $variant_node->selectedOptions ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
 						foreach ( $variant_node->selectedOptions as $selectedOption ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
-							$variation_data['attributes'][ $selectedOption->name ] = $selectedOption->value; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
+							$variation_data['attributes'][ wc_clean( $selectedOption->name ) ] = wc_clean( $selectedOption->value ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
 						}
 					}
 				}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php
index 644c383289..192e30e88c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php
@@ -527,6 +527,75 @@ class ShopifyMapperTest extends \WC_Unit_Test_Case {
 		$this->assertEquals( array(), $result['images'] );
 	}

+	/**
+	 * Test tax status mapping for simple products.
+	 */
+	public function test_tax_status_mapping_simple_product() {
+		// Test non-taxable simple product.
+		$non_taxable_product                                    = $this->create_simple_shopify_product();
+		$non_taxable_product->variants->edges[0]->node->taxable = false;
+
+		$result = $this->mapper->map_product_data( $non_taxable_product );
+
+		$this->assertEquals( 'none', $result['tax_status'] );
+
+		// Test taxable simple product.
+		$taxable_product                                    = $this->create_simple_shopify_product();
+		$taxable_product->variants->edges[0]->node->taxable = true;
+
+		$result = $this->mapper->map_product_data( $taxable_product );
+
+		$this->assertEquals( 'taxable', $result['tax_status'] );
+	}
+
+	/**
+	 * Test tax status mapping for variable products.
+	 */
+	public function test_tax_status_mapping_variable_product() {
+		$variable_product = $this->create_variable_shopify_product();
+
+		// Set first variation as non-taxable, second as taxable.
+		$variable_product->variants->edges[0]->node->taxable = false;
+		$variable_product->variants->edges[1]->node->taxable = true;
+
+		$result = $this->mapper->map_product_data( $variable_product );
+
+		$this->assertTrue( $result['is_variable'] );
+		$this->assertCount( 2, $result['variations'] );
+
+		// Check first variation is not taxable.
+		$this->assertEquals( 'none', $result['variations'][0]['tax_status'] );
+
+		// Check second variation is taxable.
+		$this->assertEquals( 'taxable', $result['variations'][1]['tax_status'] );
+	}
+
+	/**
+	 * Test tax status mapping when taxable field is missing (backwards compatibility).
+	 */
+	public function test_tax_status_mapping_missing_field() {
+		// Test simple product without taxable field.
+		$simple_product = $this->create_simple_shopify_product();
+		// Intentionally don't set taxable field.
+
+		$result = $this->mapper->map_product_data( $simple_product );
+
+		// Should not have tax_status key when taxable field is missing.
+		$this->assertArrayNotHasKey( 'tax_status', $result );
+
+		// Test variable product without taxable field.
+		$variable_product = $this->create_variable_shopify_product();
+		// Intentionally don't set taxable field on variants.
+
+		$result = $this->mapper->map_product_data( $variable_product );
+
+		$this->assertTrue( $result['is_variable'] );
+		// Variations should not have tax_status key when taxable field is missing.
+		foreach ( $result['variations'] as $variation ) {
+			$this->assertArrayNotHasKey( 'tax_status', $variation );
+		}
+	}
+
 	/**
 	 * Create a simple Shopify product object for testing.
 	 *