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 & 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