Commit de9c6b30164 for woocommerce

commit de9c6b30164f6045a646e67b95213090b258ece9
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Fri Aug 14 12:56:05 2026 +0300

    Fix product CSV importer breaking when file path contains special characters (#66986)

    * Fix product CSV importer breaking when file path contains special characters

    * Cover character_encoding in next step link URL encoding test

    * Add test for CSV importer failing on unopenable file

    * Handle fgetcsv returning false for an empty CSV file

    * Close the CSV file handle after reading

    * Remove resolved PHPStan baseline entry for CSV importer

    * Throw RuntimeException when the CSV file cannot be opened

    * Suppress the fopen warning so error handlers cannot preempt the importer exception

    * Remove PHPStan baseline entries resolved by OnboardingTasks docblock fix

diff --git a/plugins/woocommerce/changelog/fix-29727-csv-importer-plus-in-path b/plugins/woocommerce/changelog/fix-29727-csv-importer-plus-in-path
new file mode 100644
index 00000000000..ac5a4b30227
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-29727-csv-importer-plus-in-path
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix product CSV importer failing between steps when the file path contains special characters such as '+', and show a clear error when the CSV file cannot be opened.
diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
index 7f05292d2c5..1d5a89c709c 100644
--- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
+++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
@@ -213,13 +213,14 @@ class WC_Product_CSV_Importer_Controller {
 			return '';
 		}

+		// add_query_arg() does not encode values, so characters like '+' in request-derived strings would be decoded as a space on the next request.
 		$params = array(
 			'step'               => $keys[ $step_index + 1 ],
-			'file'               => str_replace( DIRECTORY_SEPARATOR, '/', $this->file ),
-			'delimiter'          => $this->delimiter,
+			'file'               => rawurlencode( str_replace( DIRECTORY_SEPARATOR, '/', $this->file ) ),
+			'delimiter'          => rawurlencode( $this->delimiter ),
 			'update_existing'    => $this->update_existing,
 			'map_preferences'    => $this->map_preferences,
-			'character_encoding' => $this->character_encoding,
+			'character_encoding' => rawurlencode( $this->character_encoding ),
 			'_wpnonce'           => wp_create_nonce( 'woocommerce-csv-importer' ), // wp_nonce_url() escapes & to &amp; breaking redirects.
 		);

diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index 350a7ea622f..e9e8f66439d 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -101,55 +101,65 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {

 	/**
 	 * Read file.
+	 *
+	 * @throws RuntimeException When the file cannot be opened.
 	 */
 	protected function read_file() {
 		if ( ! WC_Product_CSV_Importer_Controller::is_file_valid_csv( $this->file ) ) {
 			wp_die( esc_html__( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) );
 		}

-		$handle = fopen( $this->file, 'r' ); // @codingStandardsIgnoreLine.
+		$handle = @fopen( $this->file, 'r' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- warning suppressed so a strict error handler cannot preempt the RuntimeException below.

-		if ( false !== $handle ) {
-			$this->raw_keys = array_map( 'trim', fgetcsv( $handle, 0, $this->params['delimiter'], $this->params['enclosure'], $this->params['escape'] ) ); // @codingStandardsIgnoreLine
+		if ( false === $handle ) {
+			// An exception rather than wp_die(), so callers in any context (admin, AJAX, REST, CLI) can catch and present it appropriately.
+			throw new RuntimeException( esc_html__( 'Unable to open the CSV file, please try again with a new file.', 'woocommerce' ) );
+		}

-			if ( ArrayUtil::is_truthy( $this->params, 'character_encoding' ) ) {
-				$this->raw_keys = array_map( array( $this, 'adjust_character_encoding' ), $this->raw_keys );
-			}
+		$headers = fgetcsv( $handle, 0, $this->params['delimiter'], $this->params['enclosure'], $this->params['escape'] ); // @codingStandardsIgnoreLine

-			// Remove line breaks in keys, to avoid mismatch mapping of keys.
-			$this->raw_keys = wc_clean( wp_unslash( $this->raw_keys ) );
+		// fgetcsv() returns false for an empty file; leave the keys empty so the empty-file error can be shown instead of fataling on array_map().
+		$this->raw_keys = is_array( $headers ) ? array_map( 'trim', $headers ) : array();

-			// Remove BOM signature from the first item.
-			if ( isset( $this->raw_keys[0] ) ) {
-				$this->raw_keys[0] = $this->remove_utf8_bom( $this->raw_keys[0] );
-			}
+		if ( ArrayUtil::is_truthy( $this->params, 'character_encoding' ) ) {
+			$this->raw_keys = array_map( array( $this, 'adjust_character_encoding' ), $this->raw_keys );
+		}

-			if ( 0 !== $this->params['start_pos'] ) {
-				fseek( $handle, (int) $this->params['start_pos'] );
-			}
+		// Remove line breaks in keys, to avoid mismatch mapping of keys.
+		$this->raw_keys = wc_clean( wp_unslash( $this->raw_keys ) );

-			while ( 1 ) {
-				$row = fgetcsv( $handle, 0, $this->params['delimiter'], $this->params['enclosure'], $this->params['escape'] ); // @codingStandardsIgnoreLine
+		// Remove BOM signature from the first item.
+		if ( isset( $this->raw_keys[0] ) ) {
+			$this->raw_keys[0] = $this->remove_utf8_bom( $this->raw_keys[0] );
+		}

-				if ( false !== $row ) {
-					if ( ArrayUtil::is_truthy( $this->params, 'character_encoding' ) ) {
-						$row = array_map( array( $this, 'adjust_character_encoding' ), $row );
-					}
+		if ( 0 !== $this->params['start_pos'] ) {
+			fseek( $handle, (int) $this->params['start_pos'] );
+		}

-					$this->raw_data[]                                 = $row;
-					$this->file_positions[ count( $this->raw_data ) ] = ftell( $handle );
+		while ( 1 ) {
+			$row = fgetcsv( $handle, 0, $this->params['delimiter'], $this->params['enclosure'], $this->params['escape'] ); // @codingStandardsIgnoreLine

-					if ( ( $this->params['end_pos'] > 0 && ftell( $handle ) >= $this->params['end_pos'] ) || 0 === --$this->params['lines'] ) {
-						break;
-					}
-				} else {
+			if ( false !== $row ) {
+				if ( ArrayUtil::is_truthy( $this->params, 'character_encoding' ) ) {
+					$row = array_map( array( $this, 'adjust_character_encoding' ), $row );
+				}
+
+				$this->raw_data[]                                 = $row;
+				$this->file_positions[ count( $this->raw_data ) ] = ftell( $handle );
+
+				if ( ( $this->params['end_pos'] > 0 && ftell( $handle ) >= $this->params['end_pos'] ) || 0 === --$this->params['lines'] ) {
 					break;
 				}
+			} else {
+				break;
 			}
-
-			$this->file_position = ftell( $handle );
 		}

+		$this->file_position = ftell( $handle );
+
+		fclose( $handle ); // @codingStandardsIgnoreLine.
+
 		if ( ! empty( $this->params['mapping'] ) ) {
 			$this->set_mapped_keys();
 		}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index b70a433066f..8205602bfeb 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -20220,12 +20220,6 @@ parameters:
 			count: 1
 			path: includes/import/class-wc-product-csv-importer.php

-		-
-			message: '#^Parameter \#2 \$array of function array_map expects array, list\<string\|null\>\|false\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/import/class-wc-product-csv-importer.php
-
 		-
 			message: '#^Parameter \#2 \$array of function array_map expects array, list\<string\|null\>\|null given\.$#'
 			identifier: argument.type
@@ -38841,18 +38835,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/OnboardingProfile.php

-		-
-			message: '#^Access to offset ''imported'' on an unknown class Automattic\\WooCommerce\\Admin\\API\\WP_Error\.$#'
-			identifier: class.notFound
-			count: 3
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Access to offset ''imported'' on an unknown class Automattic\\WooCommerce\\Admin\\API\\WP_REST_Response\.$#'
-			identifier: class.notFound
-			count: 3
-			path: src/Admin/API/OnboardingTasks.php
-
 		-
 			message: '#^Call to an undefined method object\:\:dismiss\(\)\.$#'
 			identifier: method.notFound
@@ -39129,24 +39111,6 @@ parameters:
 			count: 1
 			path: src/Admin/API/OnboardingTasks.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:import_sample_products_from_csv\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_Error\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:import_sample_products_from_csv\(\) has invalid return type Automattic\\WooCommerce\\Admin\\API\\WP_REST_Response\.$#'
-			identifier: class.notFound
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
-		-
-			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:import_sample_products_from_csv\(\) should return Automattic\\WooCommerce\\Admin\\API\\WP_Error\|Automattic\\WooCommerce\\Admin\\API\\WP_REST_Response but returns WP_Error\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Admin/API/OnboardingTasks.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Admin\\API\\OnboardingTasks\:\:register_routes\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
index 71411fc0f61..69f59f6c66f 100644
--- a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
+++ b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
@@ -316,7 +316,7 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
 	 * Import sample products from given CSV path.
 	 *
 	 * @param  string $csv_file CSV file path.
-	 * @return WP_Error|WP_REST_Response
+	 * @return \WP_Error|array
 	 */
 	public static function import_sample_products_from_csv( $csv_file ) {
 		include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
@@ -325,15 +325,29 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
 			// Override locale so we can return mappings from WooCommerce in English language stores.
 			add_filter( 'locale', '__return_false', 9999 );
 			$importer_class = apply_filters( 'woocommerce_product_csv_importer_class', 'WC_Product_CSV_Importer' );
-			$args           = array(
-				'parse'   => true,
-				'mapping' => self::get_header_mappings( $csv_file ),
-			);
-			$args           = apply_filters( 'woocommerce_product_csv_importer_args', $args, $importer_class );

-			$importer = new $importer_class( $csv_file, $args );
-			$import   = $importer->import();
-			return $import;
+			try {
+				$args = array(
+					'parse'   => true,
+					'mapping' => self::get_header_mappings( $csv_file ),
+				);
+
+				/**
+				 * Filter the arguments used by the product CSV importer.
+				 *
+				 * @since 3.1.0
+				 *
+				 * @param array  $args           Importer arguments.
+				 * @param string $importer_class Importer class name.
+				 */
+				$args = apply_filters( 'woocommerce_product_csv_importer_args', $args, $importer_class );
+
+				$importer = new $importer_class( $csv_file, $args );
+				$import   = $importer->import();
+				return $import;
+			} catch ( \RuntimeException $e ) {
+				return new \WP_Error( 'woocommerce_rest_import_error', $e->getMessage() );
+			}
 		} else {
 			return new \WP_Error( 'woocommerce_rest_import_error', __( 'Sorry, the sample products data file was not found.', 'woocommerce' ) );
 		}
diff --git a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
index b609776d322..3fd85e8e208 100644
--- a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
@@ -57,4 +57,31 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
 			$columns
 		);
 	}
+
+	/**
+	 * @testdox Should URL-encode request-derived values in the next step link so special characters like '+' survive the round trip.
+	 */
+	public function test_get_next_step_link_url_encodes_request_derived_params(): void {
+		$file               = '/tmp/+dir with spaces/import.csv';
+		$delimiter          = '+';
+		$character_encoding = 'UTF-8+custom';
+
+		$_REQUEST['step']               = 'upload';
+		$_REQUEST['file']               = $file;
+		$_REQUEST['delimiter']          = $delimiter;
+		$_REQUEST['character_encoding'] = $character_encoding;
+
+		try {
+			$controller = new WC_Product_CSV_Importer_Controller();
+			$url        = $controller->get_next_step_link();
+		} finally {
+			unset( $_REQUEST['step'], $_REQUEST['file'], $_REQUEST['delimiter'], $_REQUEST['character_encoding'] );
+		}
+
+		parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $params );
+
+		$this->assertSame( $file, $params['file'], 'The file path should survive the query string round trip unchanged' );
+		$this->assertSame( $delimiter, $params['delimiter'], 'The delimiter should survive the query string round trip unchanged' );
+		$this->assertSame( $character_encoding, $params['character_encoding'], 'The character encoding should survive the query string round trip unchanged' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index 4d9dd4f0e88..830d80232be 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -834,6 +834,33 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( '', $importer->parse_float_field( '' ), 'Empty values should be returned unchanged.' );
 	}

+	/**
+	 * @testdox Constructing the importer with a CSV file that cannot be opened should fail with a clear error.
+	 */
+	public function test_unopenable_csv_file_fails_with_clear_error() {
+		$this->expectException( RuntimeException::class );
+		$this->expectExceptionMessage( 'Unable to open the CSV file, please try again with a new file.' );
+
+		new WC_Product_CSV_Importer( __DIR__ . '/does-not-exist.csv' );
+	}
+
+	/**
+	 * @testdox Importing an empty CSV file should yield empty keys and data instead of fataling.
+	 */
+	public function test_empty_csv_file_yields_empty_keys_and_data() {
+		$empty_csv = sys_get_temp_dir() . '/empty-import.csv';
+		file_put_contents( $empty_csv, '' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- test fixture.
+
+		try {
+			$importer = new WC_Product_CSV_Importer( $empty_csv );
+
+			$this->assertSame( array(), $importer->get_raw_keys(), 'An empty CSV file should produce no raw keys' );
+			$this->assertSame( array(), $importer->get_raw_data(), 'An empty CSV file should produce no raw data' );
+		} finally {
+			unlink( $empty_csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- test fixture.
+		}
+	}
+
 	/**
 	 * @testdox adjust_character_encoding should convert values from the configured encoding to UTF-8 (issue #38541).
 	 * @dataProvider provider_adjust_character_encoding