Commit c4ea1f6c3ae for woocommerce

commit c4ea1f6c3aeeda5b35c93aec38c61178b9c6a590
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Wed Sep 23 11:52:15 2026 +0200

    Fix downloads of local files with spaces in filenames (#68948)

    * Fix downloads of local files with spaces in filenames

    * Add changelog entry for local file download fix

    * Clarify encoded-space download fallback

    * improve comment

    * Simplify encoded-space download regression test

    * fix unit test

    * add comment

diff --git a/plugins/woocommerce/changelog/fix-56097-download-files-with-spaces b/plugins/woocommerce/changelog/fix-56097-download-files-with-spaces
new file mode 100644
index 00000000000..565211f7d81
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-56097-download-files-with-spaces
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix downloads of local product files with spaces in their filenames.
diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php
index e2362b021be..932c8c6200d 100644
--- a/plugins/woocommerce/includes/class-wc-download-handler.php
+++ b/plugins/woocommerce/includes/class-wc-download-handler.php
@@ -318,17 +318,18 @@ class WC_Download_Handler {
 		 * via filters we can still do the string replacement on a HTTP file.
 		 */
 		$replacements = array(
-			$wp_uploads_url                                                   => $wp_uploads_dir,
-			network_site_url( '/', 'https' )                                  => ABSPATH,
+			$wp_uploads_url                  => $wp_uploads_dir,
+			network_site_url( '/', 'https' ) => ABSPATH,
 			str_replace( 'https:', 'http:', network_site_url( '/', 'http' ) ) => ABSPATH,
-			site_url( '/', 'https' )                                          => ABSPATH,
-			str_replace( 'https:', 'http:', site_url( '/', 'http' ) )         => ABSPATH,
+			site_url( '/', 'https' )         => ABSPATH,
+			str_replace( 'https:', 'http:', site_url( '/', 'http' ) ) => ABSPATH,
 		);

-		$count            = 0;
-		$file_path        = str_replace( array_keys( $replacements ), array_values( $replacements ), $file_path, $count );
-		$parsed_file_path = wp_parse_url( $file_path );
-		$remote_file      = null === $count || 0 === $count; // Remote file only if there were no replacements.
+		$count             = 0;
+		$file_path         = str_replace( array_keys( $replacements ), array_values( $replacements ), $file_path, $count );
+		$parsed_file_path  = wp_parse_url( $file_path );
+		$remote_file       = null === $count || 0 === $count; // Remote file only if there were no replacements.
+		$decoded_file_path = str_replace( '%20', ' ', $file_path );

 		// Paths that begin with '//' are always remote URLs.
 		if ( '//' === substr( $file_path, 0, 2 ) ) {
@@ -357,6 +358,10 @@ class WC_Download_Handler {
 			$remote_file = false;
 			$file_path   = realpath( WP_CONTENT_DIR . substr( $file_path, strlen( $wp_content_dirname ) ) );

+			// A mapped local URL may encode spaces as "%20". Use the decoded path only when the literal path does not exist.
+		} elseif ( ! $remote_file && $decoded_file_path !== $file_path && ! file_exists( $file_path ) && file_exists( $decoded_file_path ) ) {
+			$file_path = $decoded_file_path;
+
 			// Check if we have an absolute path.
 		} elseif ( ( ! isset( $parsed_file_path['scheme'] ) || ! in_array( $parsed_file_path['scheme'], array( 'http', 'https', 'ftp' ), true ) ) && isset( $parsed_file_path['path'] ) ) {
 			$remote_file = false;
@@ -820,7 +825,7 @@ class WC_Download_Handler {
 	 *
 	 * @return string Content disposition value.
 	 */
-	private static function get_content_disposition() : string {
+	private static function get_content_disposition(): string {
 		$disposition = 'attachment';
 		if ( 'yes' === get_option( 'woocommerce_downloads_deliver_inline' ) ) {
 			$disposition = 'inline';
@@ -907,7 +912,7 @@ class WC_Download_Handler {

 				echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Download chunks are raw binary data and must not be HTML-escaped.
 				$output_sent = $output_sent || '' !== $chunk;
-				$p = @ftell( $handle ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
+				$p           = @ftell( $handle ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged

 				if ( ob_get_length() ) {
 					ob_flush();
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
index 0992cfe5342..e9bcb2142da 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
@@ -26,6 +26,28 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case {
 		$this->assertFalse( $parsed_file_path['remote_file'] );
 	}

+	/**
+	 * @testdox Encoded spaces in a local URL resolve to an existing file with spaces in its name.
+	 */
+	public function test_parse_file_path_for_encoded_space_in_existing_file(): void {
+		$uploads       = wp_upload_dir();
+		$filename      = 'wc download ' . wp_generate_uuid4() . '.pdf';
+		$absolute_path = trailingslashit( $uploads['basedir'] ) . $filename;
+		$file_url      = trailingslashit( $uploads['baseurl'] ) . rawurlencode( $filename );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture in the uploads directory.
+		$this->assertNotFalse( file_put_contents( $absolute_path, 'download fixture' ) );
+
+		try {
+			$parsed_file_path = WC_Download_Handler::parse_file_path( $file_url );
+			$this->assertFalse( $parsed_file_path['remote_file'] );
+			$this->assertSame( $absolute_path, $parsed_file_path['file_path'] );
+		} finally {
+			// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Remove test fixtures from the uploads directory.
+			unlink( $absolute_path );
+		}
+	}
+
 	/**
 	 * Test for local file with `file` protocol.
 	 */